From 4ba67e934c37cc7e3624a70deb4fb6a9dc57a5e6 Mon Sep 17 00:00:00 2001 From: bosunUbuntu Date: Mon, 2 Mar 2026 01:18:14 +0100 Subject: [PATCH] Refactor Soroban Contracts to Remove Off-chain Data --- contracts/course/course_access/src/error.rs | 10 +- .../src/functions/save_profile.rs | 37 +- contracts/course/course_access/src/lib.rs | 60 +-- contracts/course/course_access/src/schema.rs | 19 +- contracts/course/course_access/src/test.rs | 2 +- contracts/course/course_registry/src/error.rs | 12 +- .../course_registry/src/functions/add_goal.rs | 129 ++---- .../src/functions/add_module.rs | 108 +++-- .../src/functions/archive_course.rs | 20 +- .../src/functions/backup_recovery.rs | 2 +- .../src/functions/create_course.rs | 363 ++++----------- .../src/functions/create_prerequisite.rs | 25 +- .../src/functions/delete_course.rs | 42 +- .../src/functions/edit_course.rs | 296 ++++-------- .../src/functions/edit_goal.rs | 61 ++- .../src/functions/edit_prerequisite.rs | 132 +++--- .../src/functions/get_course.rs | 9 +- .../functions/get_courses_by_instructor.rs | 21 +- .../functions/get_prerequisites_by_course.rs | 4 +- .../src/functions/is_course_creator.rs | 10 +- .../functions/list_courses_with_filters.rs | 160 +------ .../src/functions/list_modules.rs | 93 +++- .../src/functions/remove_goal.rs | 66 +-- .../src/functions/remove_module.rs | 10 +- .../src/functions/remove_prerequisite.rs | 45 +- .../course_registry/src/functions/utils.rs | 7 +- contracts/course/course_registry/src/lib.rs | 437 +++--------------- .../course/course_registry/src/schema.rs | 45 +- contracts/course/course_registry/src/test.rs | 93 ++-- ...est_remove_module_storage_isolation.1.json | 104 +---- .../test/test_remove_module_success.1.json | 86 +--- ...t_remove_multiple_different_modules.1.json | 88 +--- contracts/user_profile/src/error.rs | 3 +- .../src/functions/get_user_profile.rs | 22 - contracts/user_profile/src/lib.rs | 27 +- contracts/user_profile/src/schema.rs | 23 +- contracts/user_profile/src/test.rs | 168 +++---- 37 files changed, 891 insertions(+), 1948 deletions(-) diff --git a/contracts/course/course_access/src/error.rs b/contracts/course/course_access/src/error.rs index b8ff0ec..ca00b07 100644 --- a/contracts/course/course_access/src/error.rs +++ b/contracts/course/course_access/src/error.rs @@ -7,18 +7,16 @@ use soroban_sdk::{contracterror, panic_with_error, Env}; #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum Error { - UserAlreadyHasAccess = 1, - UserNoAccessCourse = 2, + UserAlreadyHasAccess = 1, + UserNoAccessCourse = 2, Unauthorized = 3, - NameRequired = 4, - EmailRequired = 5, - CountryRequired = 6, InvalidCourseId = 7, InvalidUser = 8, EmptyCourseId = 9, InvalidTransferData = 10, SameUserTransfer = 11, - Initialized = 12 + Initialized = 12, + OffChainRefIdRequired = 13, } pub fn handle_error(env: &Env, error: Error) -> ! { diff --git a/contracts/course/course_access/src/functions/save_profile.rs b/contracts/course/course_access/src/functions/save_profile.rs index 356364c..103199a 100644 --- a/contracts/course/course_access/src/functions/save_profile.rs +++ b/contracts/course/course_access/src/functions/save_profile.rs @@ -9,33 +9,28 @@ use crate::schema::{DataKey, UserProfile}; const SAVE_USER_PROFILE_EVENT: Symbol = symbol_short!("saveUsPrl"); +/// Save a minimal on-chain user profile. +/// +/// Stores only the user's address and an off-chain reference ID. +/// All PII (name, email, profession, goals, country) is stored off-chain. +/// +/// # Arguments +/// * `env` - Soroban environment +/// * `off_chain_ref_id` - UUID/hash mapping to the user's full record in the off-chain DB +/// * `user` - The user's blockchain address pub fn save_user_profile( env: Env, - name: String, - email: String, - profession: Option, - goals: Option, - country: String, + off_chain_ref_id: String, user: Address, ) { - // Validate required fields - if name.is_empty() { - handle_error(&env, Error::NameRequired) - } - // TODO: Implement full email validation according to RFC 5322 standard - if email.is_empty() { - handle_error(&env, Error::EmailRequired) - } - if country.is_empty() { - handle_error(&env, Error::CountryRequired) + // Validate required field + if off_chain_ref_id.is_empty() { + handle_error(&env, Error::OffChainRefIdRequired) } let profile: UserProfile = UserProfile { - name: name.clone(), - email: email.clone(), - profession: profession.clone(), - goals: goals.clone(), - country: country.clone(), + user: user.clone(), + off_chain_ref_id: off_chain_ref_id.clone(), }; env.storage() @@ -43,5 +38,5 @@ pub fn save_user_profile( .set(&DataKey::UserProfile(user.clone()), &profile); env.events() - .publish((SAVE_USER_PROFILE_EVENT,), (name, email, profession, goals, country, user)); + .publish((SAVE_USER_PROFILE_EVENT,), (user, off_chain_ref_id)); } diff --git a/contracts/course/course_access/src/lib.rs b/contracts/course/course_access/src/lib.rs index 050f8b7..f7f314a 100644 --- a/contracts/course/course_access/src/lib.rs +++ b/contracts/course/course_access/src/lib.rs @@ -15,14 +15,14 @@ mod test; use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; -use functions::{config::initialize,config::set_contract_addrs, grant_access::course_access_grant_access, revoke_access::course_access_revoke_access, revoke_all_access::revoke_all_access, save_profile::save_user_profile, list_user_courses::list_user_courses, list_course_access::course_access_list_course_access, contract_versioning::{is_version_compatible, get_migration_status, get_version_history, migrate_access_data}, transfer_course_access::transfer_course_access}; +use functions::{config::initialize, config::set_contract_addrs, grant_access::course_access_grant_access, revoke_access::course_access_revoke_access, revoke_all_access::revoke_all_access, save_profile::save_user_profile, list_user_courses::list_user_courses, list_course_access::course_access_list_course_access, contract_versioning::{is_version_compatible, get_migration_status, get_version_history, migrate_access_data}, transfer_course_access::transfer_course_access}; use schema::{CourseUsers, UserCourses}; /// Course Access Contract /// /// This contract manages user access to courses in the SkillCert platform. /// It provides functionality to grant, revoke, and query course access permissions, -/// as well as manage user profiles. +/// as well as store minimal on-chain user profiles (address + off-chain ref ID only). #[contract] pub struct CourseAccessContract; @@ -163,65 +163,25 @@ impl CourseAccessContract { course_access_revoke_access(env, course_id, user) } - /// Save or update a user's profile on-chain. + /// Save or update a minimal on-chain user profile. /// - /// Stores user profile information in the contract storage. - /// This includes personal and professional information. + /// Stores only the user's address and an off-chain reference ID. + /// All PII (name, email, profession, goals, country) is stored off-chain. /// /// # Arguments /// /// * `env` - The Soroban environment - /// * `name` - The user's full name - /// * `email` - The user's email address - /// * `profession` - Optional profession/job title - /// * `goals` - Optional learning goals or objectives - /// * `country` - The user's country of residence + /// * `off_chain_ref_id` - UUID/hash mapping to the user's full record in the off-chain DB /// /// # Panics /// - /// * If name, email, or country are empty - /// * If email format is invalid - /// - /// # Examples - /// - /// ```rust - /// // Save user profile - /// contract.save_user_profile( - /// env.clone(), - /// "John Doe".try_into().unwrap(), - /// "john@example.com".try_into().unwrap(), - /// Some("Software Developer".try_into().unwrap()), - /// Some("Learn Rust programming".try_into().unwrap()), - /// "US".try_into().unwrap() - /// ); - /// - /// // Save minimal profile - /// contract.save_user_profile( - /// env.clone(), - /// "Jane Smith".try_into().unwrap(), - /// "jane@example.com".try_into().unwrap(), - /// None, - /// None, - /// "CA".try_into().unwrap() - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Empty required fields**: Name, email, and country cannot be empty - /// * **Invalid email**: Email must be in valid format - /// * **Profile updates**: Overwrites existing profile data - /// * **Optional fields**: Profession and goals can be None + /// * If off_chain_ref_id is empty pub fn save_user_profile( env: Env, - name: String, - email: String, - profession: Option, - goals: Option, - country: String, + off_chain_ref_id: String, ) { let user: Address = env.current_contract_address(); - save_user_profile(env, name, email, profession, goals, country, user); + save_user_profile(env, off_chain_ref_id, user); } /// List all courses a user has access to. @@ -463,7 +423,7 @@ impl CourseAccessContract { get_migration_status(&env) } - pub fn transfer_course(env: Env, course_id: String, from: Address, to: Address){ + pub fn transfer_course(env: Env, course_id: String, from: Address, to: Address) { transfer_course_access(env, course_id, from, to) } } diff --git a/contracts/course/course_access/src/schema.rs b/contracts/course/course_access/src/schema.rs index c57989c..7b24a55 100644 --- a/contracts/course/course_access/src/schema.rs +++ b/contracts/course/course_access/src/schema.rs @@ -46,23 +46,16 @@ pub enum DataKey { CourseUsers(String), } -/// Represents a user's profile information. +/// on-chain user profile for the course_access contract. /// -/// This struct contains all the personal and professional information -/// that users can store on-chain as part of their profile. +/// Stores only the user's blockchain address and an off-chain reference ID. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct UserProfile { - /// The user's full name - pub name: String, - /// The user's email address - pub email: String, - /// Optional profession or job title - pub profession: Option, - /// Optional learning goals or objectives - pub goals: Option, - /// The user's country of residence - pub country: String, + /// The user's blockchain address + pub user: Address, + /// Off-chain reference ID (UUID mapping to DB record) + pub off_chain_ref_id: String, } /// Contains all users who have access to a specific course. diff --git a/contracts/course/course_access/src/test.rs b/contracts/course/course_access/src/test.rs index a8c634c..ff8ec19 100644 --- a/contracts/course/course_access/src/test.rs +++ b/contracts/course/course_access/src/test.rs @@ -21,7 +21,7 @@ mod user_management { // For testing, always return true to simplify admin checks true } - pub fn save_user_profile(_env: Env, _user: Address, _name: String, _email: String) { + pub fn save_user_profile(_env: Env, _off_chain_ref_id: String) { // Mock implementation } pub fn is_course_creator(_env: Env, _course_id: String, _user: Address) -> bool { diff --git a/contracts/course/course_registry/src/error.rs b/contracts/course/course_registry/src/error.rs index 191c6ce..2edae63 100644 --- a/contracts/course/course_registry/src/error.rs +++ b/contracts/course/course_registry/src/error.rs @@ -13,10 +13,9 @@ pub enum Error { OnlyCreatorCanArchive = 4, CourseAlreadyArchived = 5, Unauthorized = 6, + /// Category name is required (used by create_course_category) NameRequired = 7, - EmptyCourseTitle = 8, InvalidPrice = 9, - DuplicateCourseTitle = 10, DuplicateCourseId = 11, OnlyCreatorCanEditPrereqs = 12, PrereqCourseNotFound = 13, @@ -31,18 +30,13 @@ pub enum Error { UnauthorizedCaller = 401, UnauthorizedCourseAccess = 402, InvalidAdminOperation = 403, - EmptyModuleTitle = 404, DuplicateModulePosition = 405, EmptyModuleId = 22, PrereqNotInList = 23, InvalidModulePosition = 24, - InvalidModuleTitle = 25, - InvalidCourseDescription = 26, InvalidCategoryName = 27, EmptyCategory = 28, - InvalidTitleLength = 29, InvalidLanguageLength = 43, - InvalidThumbnailUrlLength = 44, InvalidDurationValue = 45, InvalidLimitValue = 46, InvalidOffsetValue = 47, @@ -58,6 +52,10 @@ pub enum Error { // Rate limiting errors CourseRateLimitExceeded = 57, CourseRateLimitNotConfigured = 58, + /// Content hash is required for on-chain integrity verification + ContentHashRequired = 59, + /// Off-chain reference ID is required + OffChainRefIdRequired = 60, } pub fn handle_error(env: &Env, error: Error) -> ! { diff --git a/contracts/course/course_registry/src/functions/add_goal.rs b/contracts/course/course_registry/src/functions/add_goal.rs index 56a2193..c3777a6 100644 --- a/contracts/course/course_registry/src/functions/add_goal.rs +++ b/contracts/course/course_registry/src/functions/add_goal.rs @@ -4,34 +4,34 @@ use soroban_sdk::{symbol_short, Address, Env, String, Symbol}; use crate::error::{handle_error, Error}; -use crate::functions::utils::{self, trim}; +use crate::functions::utils; use crate::schema::{Course, CourseGoal, DataKey}; const COURSE_KEY: Symbol = symbol_short!("course"); const GOAL_ADDED_EVENT: Symbol = symbol_short!("goalAdded"); -pub fn add_goal(env: Env, creator: Address, course_id: String, content: String) -> CourseGoal { +/// Add a goal to a course. +/// +/// The caller provides a content_hash +/// representing the SHA-256 hash of the off-chain goal content for integrity verification. +pub fn add_goal(env: Env, creator: Address, course_id: String, content_hash: String) -> CourseGoal { creator.require_auth(); - + // Validate input parameters if course_id.is_empty() { handle_error(&env, Error::EmptyCourseId); } - - // Validate goal content - prevent empty or whitespace-only content - if content.is_empty() || trim(&env, &content).is_empty() { + + // Validate content hash is provided + if content_hash.is_empty() { handle_error(&env, Error::EmptyGoalContent); } - + // Check string lengths to prevent extremely long values if course_id.len() > 100 { handle_error(&env, Error::InvalidCourseId); } - - if content.len() > 1000 { - handle_error(&env, Error::InvalidGoalContent); - } // Load course let storage_key: (Symbol, String) = (COURSE_KEY, course_id.clone()); @@ -49,11 +49,11 @@ pub fn add_goal(env: Env, creator: Address, course_id: String, content: String) // Generate a unique goal ID let goal_id = utils::generate_unique_id(&env); - // Create new goal + // Create new goal — lean on-chain record let goal: CourseGoal = CourseGoal { course_id: course_id.clone(), goal_id: goal_id.clone(), - content: content.clone(), + content_hash: content_hash.clone(), created_by: creator.clone(), created_at: env.ledger().timestamp(), }; @@ -64,10 +64,10 @@ pub fn add_goal(env: Env, creator: Address, course_id: String, content: String) &goal, ); - // Emit event + // Emit event — only essential blockchain data env.events().publish( (GOAL_ADDED_EVENT, course_id.clone(), goal_id.clone()), - content.clone(), + content_hash.clone(), ); goal @@ -79,6 +79,22 @@ mod test { use crate::{CourseRegistry, CourseRegistryClient}; use soroban_sdk::{testutils::Address as _, Address, Env, String}; + fn create_test_course<'a>( + client: &CourseRegistryClient<'a>, + creator: &Address, + ) -> Course { + client.create_course( + creator, + &String::from_str(&client.env, "off_chain_ref_001"), + &String::from_str(&client.env, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"), + &1000_u128, + &Some(String::from_str(&client.env, "category")), + &Some(String::from_str(&client.env, "language")), + &None, + &None, + ) + } + #[test] fn test_add_goal_success() { let env = Env::default(); @@ -88,24 +104,13 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator: Address = Address::generate(&env); + let course: Course = create_test_course(&client, &creator); - let course: Course = client.create_course( - &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), - &1000_u128, - &Some(String::from_str(&env, "category")), - &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), - &None, - &None, - ); - - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - let goal = client.add_goal(&creator, &course.id, &goal_content); + let content_hash = String::from_str(&env, "deadbeefdeadbeefdeadbeefdeadbeef"); + let goal = client.add_goal(&creator, &course.id, &content_hash); assert_eq!(goal.course_id, course.id); - assert_eq!(goal.content, goal_content); + assert_eq!(goal.content_hash, content_hash); assert_eq!(goal.created_by, creator); } @@ -121,20 +126,10 @@ mod test { let creator: Address = Address::generate(&env); let impostor: Address = Address::generate(&env); - let course: Course = client.create_course( - &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), - &1000_u128, - &Some(String::from_str(&env, "category")), - &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), - &None, - &None, - ); + let course: Course = create_test_course(&client, &creator); - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - client.add_goal(&impostor, &course.id, &goal_content); + let content_hash = String::from_str(&env, "deadbeefdeadbeefdeadbeefdeadbeef"); + client.add_goal(&impostor, &course.id, &content_hash); } #[test] @@ -149,13 +144,13 @@ mod test { let creator: Address = Address::generate(&env); let fake_course_id = String::from_str(&env, "nonexistent_course"); - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - client.add_goal(&creator, &fake_course_id, &goal_content); + let content_hash = String::from_str(&env, "deadbeefdeadbeefdeadbeefdeadbeef"); + client.add_goal(&creator, &fake_course_id, &content_hash); } #[test] #[should_panic(expected = "HostError: Error(Contract, #2)")] - fn test_add_goal_empty_content() { + fn test_add_goal_empty_content_hash() { let env = Env::default(); env.mock_all_auths(); @@ -163,21 +158,9 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator: Address = Address::generate(&env); + let course: Course = create_test_course(&client, &creator); - let course: Course = client.create_course( - &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), - &1000_u128, - &Some(String::from_str(&env, "category")), - &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), - &None, - &None, - ); - - let goal_content = String::from_str(&env, ""); - client.add_goal(&creator, &course.id, &goal_content); + client.add_goal(&creator, &course.id, &String::from_str(&env, "")); } #[test] @@ -189,33 +172,23 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator: Address = Address::generate(&env); + let course: Course = create_test_course(&client, &creator); - let course: Course = client.create_course( - &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), - &1000_u128, - &Some(String::from_str(&env, "category")), - &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), - &None, - &None, - ); - - let goal_content1 = String::from_str(&env, "Learn the basics of Rust"); - let goal1 = client.add_goal(&creator, &course.id, &goal_content1); + let hash1 = String::from_str(&env, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"); + let goal1 = client.add_goal(&creator, &course.id, &hash1); - let goal_content2 = String::from_str(&env, "Understand ownership and borrowing"); - let goal2 = client.add_goal(&creator, &course.id, &goal_content2); + let hash2 = String::from_str(&env, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2"); + let goal2 = client.add_goal(&creator, &course.id, &hash2); assert_eq!(goal1.course_id, course.id); - assert_eq!(goal1.content, goal_content1); + assert_eq!(goal1.content_hash, hash1); assert_eq!(goal1.created_by, creator); assert_eq!(goal2.course_id, course.id); - assert_eq!(goal2.content, goal_content2); + assert_eq!(goal2.content_hash, hash2); assert_eq!(goal2.created_by, creator); assert!(goal2.created_at >= goal1.created_at); + assert_ne!(goal1.goal_id, goal2.goal_id); } } diff --git a/contracts/course/course_registry/src/functions/add_module.rs b/contracts/course/course_registry/src/functions/add_module.rs index 332f489..dd4184e 100644 --- a/contracts/course/course_registry/src/functions/add_module.rs +++ b/contracts/course/course_registry/src/functions/add_module.rs @@ -5,38 +5,38 @@ use soroban_sdk::{symbol_short, Vec, vec, Address, Env, String, Symbol}; use crate::functions::utils::{concat_strings, u32_to_string}; use crate::error::{handle_error, Error}; -use crate::schema::{CourseModule}; +use crate::schema::CourseModule; const COURSE_KEY: Symbol = symbol_short!("course"); const MODULE_KEY: Symbol = symbol_short!("module"); const COURSE_REGISTRY_ADD_MODULE_EVENT: Symbol = symbol_short!("crsAddMod"); +/// Add a module to a course. +/// +/// The caller provides a content_hash +/// representing the SHA-256 hash of the off-chain module content for integrity verification. pub fn course_registry_add_module( env: Env, caller: Address, course_id: String, position: u32, - title: String, + content_hash: String, ) -> CourseModule { // Validate input parameters if course_id.is_empty() { handle_error(&env, Error::EmptyCourseId); } - - if title.is_empty() { - handle_error(&env, Error::InvalidModuleTitle); + + if content_hash.is_empty() { + handle_error(&env, Error::ContentHashRequired); } - + // Check string lengths to prevent extremely long values if course_id.len() > 100 { handle_error(&env, Error::EmptyCourseId); } - - if title.len() > 500 { - handle_error(&env, Error::InvalidModuleTitle); - } - + // Validate position is reasonable (not extremely large) if position > 10000 { handle_error(&env, Error::InvalidModulePosition); @@ -71,12 +71,12 @@ pub fn course_registry_add_module( let module_id: String = concat_strings(&env, arr); - // Create new module + // Create new module — lean on-chain record let module: CourseModule = CourseModule { id: module_id.clone(), course_id: course_id.clone(), position, - title: title.clone(), + content_hash: content_hash.clone(), created_at: env.ledger().timestamp(), }; @@ -86,9 +86,9 @@ pub fn course_registry_add_module( env.storage().persistent().set(&storage_key, &module); env.storage().persistent().set(&position_key, &true); - // emit an event + // emit an event — only essential blockchain data env.events() - .publish((COURSE_REGISTRY_ADD_MODULE_EVENT,), (caller, course_id, position, title)); + .publish((COURSE_REGISTRY_ADD_MODULE_EVENT,), (caller, course_id, position, content_hash)); module } @@ -96,25 +96,24 @@ pub fn course_registry_add_module( #[cfg(test)] mod test { extern crate std; - + use super::*; use crate::{schema::Course, CourseRegistry, CourseRegistryClient}; use soroban_sdk::{testutils::Address as _, Address, Env}; fn create_course<'a>(client: &CourseRegistryClient<'a>, creator: &Address) -> Course { - let title = String::from_str(&client.env, "title"); - let description = String::from_str(&client.env, "description"); + let off_chain_ref_id = String::from_str(&client.env, "ref_001"); + let content_hash = String::from_str(&client.env, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"); let price = 1000_u128; client.create_course( - &creator, - &title, - &description, + creator, + &off_chain_ref_id, + &content_hash, &price, &None, &None, &None, &None, - &None, ) } @@ -138,10 +137,10 @@ mod test { fn setup_test_env() -> (Env, Address, Address, CourseRegistryClient<'static>) { let env = Env::default(); env.mock_all_auths(); - + // Register mock user management contract let user_mgmt_id = env.register(mock_user_management::UserManagement, ()); - + let contract_id = env.register(CourseRegistry, ()); let client = CourseRegistryClient::new(&env, &contract_id); @@ -160,11 +159,12 @@ mod test { let creator = Address::generate(&env); let course = create_course(&client, &creator); - let module = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 1")); + let content_hash = String::from_str(&env, "module_hash_aabbccddee1122334455"); + let module = client.add_module(&creator, &course.id, &1, &content_hash); assert_eq!(module.course_id, course.id); assert_eq!(module.position, 1); - assert_eq!(module.title, String::from_str(&env, "Module 1")); + assert_eq!(module.content_hash, content_hash); } #[test] @@ -173,12 +173,12 @@ mod test { let creator = Address::generate(&env); let course = create_course(&client, &creator); - // Admin should be able to add modules - let module = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 1")); + let content_hash = String::from_str(&env, "module_hash_aabbccddee1122334455"); + let module = client.add_module(&creator, &course.id, &1, &content_hash); assert_eq!(module.course_id, course.id); assert_eq!(module.position, 1); - assert_eq!(module.title, String::from_str(&env, "Module 1")); + assert_eq!(module.content_hash, content_hash); } #[test] @@ -186,16 +186,14 @@ mod test { fn test_add_module_unauthorized() { let (env, _, _, client) = setup_test_env(); let creator = Address::generate(&env); - let _unauthorized_user = Address::generate(&env); let course = create_course(&client, &creator); - // Unauthorized user should not be able to add modules let unauthorized_user = Address::generate(&env); client.add_module( &unauthorized_user, &course.id, &1, - &String::from_str(&env, "Module 1"), + &String::from_str(&env, "module_hash_aabbccddee1122334455"), ); } @@ -209,7 +207,7 @@ mod test { &unauthorized_user, &String::from_str(&env, "invalid_course"), &1, - &String::from_str(&env, "Module 1"), + &String::from_str(&env, "module_hash_aabbccddee1122334455"), ); } @@ -219,8 +217,18 @@ mod test { let creator = Address::generate(&env); let course = create_course(&client, &creator); - let module1 = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 1")); - let module2 = client.add_module(&creator, &course.id, &2, &String::from_str(&env, "Module 2")); + let module1 = client.add_module( + &creator, + &course.id, + &1, + &String::from_str(&env, "hash_module_one_aabbccddeeff1122"), + ); + let module2 = client.add_module( + &creator, + &course.id, + &2, + &String::from_str(&env, "hash_module_two_aabbccddeeff3344"), + ); assert_ne!(module1.id, module2.id); } @@ -231,7 +239,8 @@ mod test { let creator = Address::generate(&env); let course = create_course(&client, &creator); - let module = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 1")); + let content_hash = String::from_str(&env, "module_hash_aabbccddee1122334455"); + let module = client.add_module(&creator, &course.id, &1, &content_hash); let exists: bool = env.as_contract(&contract_id, || { env.storage() @@ -247,23 +256,27 @@ mod test { fn test_add_module_different_course_creator() { let (env, _, _, client) = setup_test_env(); let creator1 = Address::generate(&env); - let _creator2 = Address::generate(&env); - + let course1 = create_course(&client, &creator1); - + // Creator2 should not be able to add module to Creator1's course let creator2 = Address::generate(&env); - client.add_module(&creator2, &course1.id, &1, &String::from_str(&env, "Module 1")); + client.add_module( + &creator2, + &course1.id, + &1, + &String::from_str(&env, "module_hash_aabbccddee1122334455"), + ); } #[test] #[should_panic] - fn test_add_module_empty_title() { + fn test_add_module_empty_content_hash() { let (env, _, _admin, client) = setup_test_env(); let creator = Address::generate(&env); let course = create_course(&client, &creator); - // Should panic with validation error for empty title + // Should panic with validation error for empty content hash client.add_module(&creator, &course.id, &1, &String::from_str(&env, "")); } @@ -274,10 +287,17 @@ mod test { let creator = Address::generate(&env); let course = create_course(&client, &creator); + let hash = String::from_str(&env, "module_hash_aabbccddee1122334455"); + // Add first module at position 1 - client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 1")); + client.add_module(&creator, &course.id, &1, &hash); // Try to add another module at the same position - client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 2")); + client.add_module( + &creator, + &course.id, + &1, + &String::from_str(&env, "different_hash_aabbccddee11223344"), + ); } } diff --git a/contracts/course/course_registry/src/functions/archive_course.rs b/contracts/course/course_registry/src/functions/archive_course.rs index 7dc5e94..1f274c9 100644 --- a/contracts/course/course_registry/src/functions/archive_course.rs +++ b/contracts/course/course_registry/src/functions/archive_course.rs @@ -55,12 +55,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -104,12 +103,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -130,12 +128,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -158,12 +155,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); diff --git a/contracts/course/course_registry/src/functions/backup_recovery.rs b/contracts/course/course_registry/src/functions/backup_recovery.rs index 9e6560d..6b6ab4c 100644 --- a/contracts/course/course_registry/src/functions/backup_recovery.rs +++ b/contracts/course/course_registry/src/functions/backup_recovery.rs @@ -79,7 +79,7 @@ pub fn export_course_data(env: Env, caller: Address) -> CourseBackupData { id: module_id.clone(), course_id: course.id.clone(), position: 1, - title: String::from_str(&env, "Default Module"), + content_hash: String::from_str(&env, "default_content_hash"), created_at: env.ledger().timestamp(), }; modules.set(module_id, course_module); diff --git a/contracts/course/course_registry/src/functions/create_course.rs b/contracts/course/course_registry/src/functions/create_course.rs index e8ca92c..dfb8760 100644 --- a/contracts/course/course_registry/src/functions/create_course.rs +++ b/contracts/course/course_registry/src/functions/create_course.rs @@ -1,14 +1,13 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 SkillCert -use super::utils::{to_lowercase, trim, u32_to_string}; +use super::utils::u32_to_string; use super::course_rate_limit_utils::check_course_creation_rate_limit; use soroban_sdk::{symbol_short, Address, Env, String, Symbol, Vec}; use crate::error::{handle_error, Error}; use crate::schema::{Course, CourseLevel}; const COURSE_KEY: Symbol = symbol_short!("course"); -const TITLE_KEY: Symbol = symbol_short!("title"); const COURSE_ID: Symbol = symbol_short!("course"); const CREATE_COURSE_EVENT: Symbol = symbol_short!("crtCourse"); @@ -17,12 +16,11 @@ const GENERATE_COURSE_ID_EVENT: Symbol = symbol_short!("genCrsId"); pub fn create_course( env: Env, creator: Address, - title: String, - description: String, + off_chain_ref_id: String, + content_hash: String, price: u128, category: Option, language: Option, - thumbnail_url: Option, level: Option, duration_hours: Option, ) -> Course { @@ -31,20 +29,13 @@ pub fn create_course( // Check rate limiting before proceeding with course creation check_course_creation_rate_limit(&env, &creator); - // ensure the title is not empty and not just whitespace - let trimmed_title: String = trim(&env, &title); - if title.is_empty() || trimmed_title.is_empty() { - handle_error(&env, Error::EmptyCourseTitle); + // Validate required on-chain fields + if off_chain_ref_id.is_empty() { + handle_error(&env, Error::OffChainRefIdRequired); } - // Additional title validation - if title.len() > 200 { - handle_error(&env, Error::InvalidTitleLength); - } - - // Validate description - only check length, allow empty - if description.len() > 2000 { - handle_error(&env, Error::InvalidCourseDescription); + if content_hash.is_empty() { + handle_error(&env, Error::ContentHashRequired); } // ensure the price is greater than 0 @@ -65,12 +56,6 @@ pub fn create_course( } } - if let Some(ref url) = thumbnail_url { - if url.is_empty() || url.len() > 500 { - handle_error(&env, Error::InvalidThumbnailUrlLength); - } - } - if let Some(duration) = duration_hours { if duration == 0 || duration > 8760 { // 8760 hours = 1 year, reasonable maximum @@ -78,15 +63,6 @@ pub fn create_course( } } - let lowercase_title: String = to_lowercase(&env, &title); - - // to avoid duplicate title, - let title_key: (Symbol, String) = (TITLE_KEY, lowercase_title); - - if env.storage().persistent().has(&title_key) { - handle_error(&env, Error::DuplicateCourseTitle) - } - // generate the unique id let id: u128 = generate_course_id(&env); let converted_id: String = u32_to_string(&env, id as u32); @@ -97,16 +73,15 @@ pub fn create_course( handle_error(&env, Error::DuplicateCourseId) } - // create a new course + // create a new course — lean on-chain record let new_course: Course = Course { id: converted_id.clone(), - title: title.clone(), - description: description.clone(), + off_chain_ref_id: off_chain_ref_id.clone(), + content_hash: content_hash.clone(), creator: creator.clone(), price, category: category.clone(), language: language.clone(), - thumbnail_url: thumbnail_url.clone(), published: false, prerequisites: Vec::new(&env), is_archived: false, @@ -116,11 +91,10 @@ pub fn create_course( // save to the storage env.storage().persistent().set(&storage_key, &new_course); - env.storage().persistent().set(&title_key, &true); - // emit an event + // emit an event — only essential blockchain data env.events() - .publish((CREATE_COURSE_EVENT,), (converted_id, creator, title, description, price, category, language, thumbnail_url, level, duration_hours)); + .publish((CREATE_COURSE_EVENT,), (converted_id, creator, off_chain_ref_id, content_hash, price)); new_course } @@ -168,31 +142,30 @@ mod test { let creator: Address = Address::generate(&env); - let title = String::from_str(&env, "title"); - let description = String::from_str(&env, "description"); + let off_chain_ref_id = String::from_str(&env, "course_ref_001"); + let content_hash = String::from_str(&env, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"); let price = 1000_u128; let category = Some(String::from_str(&env, "category")); let language = Some(String::from_str(&env, "language")); - let thumbnail_url = Some(String::from_str(&env, "thumbnail_url")); + let course: Course = client.create_course( &creator, - &title, - &description, + &off_chain_ref_id, + &content_hash, &price, &category, &language, - &thumbnail_url, &None, &None, ); + let course = client.get_course(&course.id); - assert_eq!(course.title, title); - assert_eq!(course.description, description); + assert_eq!(course.off_chain_ref_id, off_chain_ref_id); + assert_eq!(course.content_hash, content_hash); assert_eq!(course.id, String::from_str(&env, "1")); assert_eq!(course.price, price); assert_eq!(course.category, category); assert_eq!(course.language, language); - assert_eq!(course.thumbnail_url, thumbnail_url); assert!(!course.published); } @@ -203,107 +176,38 @@ mod test { let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "title"); - let description: String = String::from_str(&env, "A description"); let price: u128 = crate::schema::DEFAULT_COURSE_PRICE; - - let another_course_title: String = String::from_str(&env, "another title"); - let another_course_description: String = String::from_str(&env, "another description"); let another_price: u128 = 2000; client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "ref_001"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), &price, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &Address::generate(&env), - &another_course_title, - &another_course_description, + &String::from_str(&env, "ref_002"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa02"), &another_price, &None, &None, &None, &None, - &None, ); let stored_course = client.get_course(&course2.id); - assert_eq!(stored_course.title, another_course_title); - assert_eq!(stored_course.description, another_course_description); + assert_eq!(stored_course.off_chain_ref_id, String::from_str(&env, "ref_002")); assert_eq!(stored_course.id, String::from_str(&env, "2")); assert_eq!(stored_course.price, another_price); } - #[test] - #[should_panic(expected = "HostError: Error(Contract, #10)")] - fn test_cannot_create_courses_with_duplicate_title() { - let env: Env = Env::default(); - env.mock_all_auths(); - let contract_id: Address = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "title"); - let description: String = String::from_str(&env, "A description"); - let another_description: String = String::from_str(&env, "another description"); - let price: u128 = crate::schema::DEFAULT_COURSE_PRICE; - - client.create_course( - &Address::generate(&env), - &title, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - - client.create_course( - &Address::generate(&env), - &title, - &another_description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - } - - #[test] - #[should_panic(expected = "HostError: Error(Contract, #8)")] - fn test_cannot_create_courses_with_empty_title() { - let env: Env = Env::default(); - env.mock_all_auths(); - let contract_id: Address = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, ""); - let description: String = String::from_str(&env, "A description"); - let price: u128 = crate::schema::DEFAULT_COURSE_PRICE; - - client.create_course( - &Address::generate(&env), - &title, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - } - #[test] #[should_panic(expected = "HostError: Error(Contract, #9)")] fn test_cannot_create_courses_with_zero_price() { @@ -311,40 +215,33 @@ mod test { env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Valid Title"); - let description: String = String::from_str(&env, "A description"); let price: u128 = 0; client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "ref_001"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), &price, &None, &None, &None, &None, - &None, ); } #[test] - #[should_panic(expected = "HostError: Error(Contract, #8)")] - fn test_cannot_create_courses_with_whitespace_only_title() { + #[should_panic] + fn test_cannot_create_course_with_empty_off_chain_ref_id() { let env: Env = Env::default(); env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, " "); - let description: String = String::from_str(&env, "A description"); - let price: u128 = crate::schema::DEFAULT_COURSE_PRICE; client.create_course( &Address::generate(&env), - &title, - &description, - &price, - &None, + &String::from_str(&env, ""), // empty off_chain_ref_id + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), + &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, @@ -353,34 +250,18 @@ mod test { } #[test] - #[should_panic(expected = "HostError: Error(Contract, #10)")] - fn test_duplicate_title_case_insensitive() { + #[should_panic] + fn test_cannot_create_course_with_empty_content_hash() { let env: Env = Env::default(); env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title1: String = String::from_str(&env, "Programming Basics"); - let title2: String = String::from_str(&env, "PROGRAMMING BASICS"); - let description: String = String::from_str(&env, "A description"); - let price: u128 = crate::schema::DEFAULT_COURSE_PRICE; client.create_course( &Address::generate(&env), - &title1, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - client.create_course( - &Address::generate(&env), - &title2, - &description, - &price, - &None, + &String::from_str(&env, "ref_001"), + &String::from_str(&env, ""), // empty content_hash + &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, @@ -388,84 +269,26 @@ mod test { ); } - #[test] - fn test_create_course_with_long_title() { - let env: Env = Env::default(); - env.mock_all_auths(); - let contract_id: Address = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - let long_title: String = String::from_str(&env, "This is a very long course title that contains many words and should still be valid for course creation as long as it is not empty"); - let description: String = String::from_str(&env, "A description"); - let price: u128 = 1500; - - let course = client.create_course( - &Address::generate(&env), - &long_title, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - assert_eq!(course.title, long_title); - assert_eq!(course.price, price); - assert_eq!(course.id, String::from_str(&env, "1")); - } - - #[test] - fn test_create_course_with_special_characters() { - let env: Env = Env::default(); - env.mock_all_auths(); - let contract_id: Address = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "C++ & JavaScript: Advanced Programming!"); - let description: String = String::from_str( - &env, - "Learn C++ and JavaScript with special symbols: @#$%^&*()", - ); - let price: u128 = 2500; - - let course = client.create_course( - &Address::generate(&env), - &title, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - assert_eq!(course.title, title); - assert_eq!(course.description, description); - assert_eq!(course.price, price); - } - #[test] fn test_create_course_with_maximum_price() { let env: Env = Env::default(); env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Premium Course"); - let description: String = String::from_str(&env, "Most expensive course"); let max_price: u128 = u128::MAX; let course = client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "premium_ref_001"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), &max_price, &None, &None, &None, &None, - &None, ); assert_eq!(course.price, max_price); - assert_eq!(course.title, title); + assert_eq!(course.id, String::from_str(&env, "1")); } #[test] @@ -474,35 +297,26 @@ mod test { env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Complete Course"); - let description: String = String::from_str(&env, "Course with all fields"); let price: u128 = 3000; let category: Option = Some(String::from_str(&env, "Web Development")); let language: Option = Some(String::from_str(&env, "Spanish")); - let thumbnail_url: Option = Some(String::from_str( - &env, - "https://example.com/course-thumbnail.png", - )); let level: Option = Some(String::from_str(&env, "Intermediate")); let duration_hours: Option = Some(40); let course = client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "complete_ref_001"), + &String::from_str(&env, "hash_complete_aaaaabbbbccccddddeee"), &price, &category, &language, - &thumbnail_url, &level, &duration_hours, ); - assert_eq!(course.title, title); - assert_eq!(course.description, description); + assert_eq!(course.off_chain_ref_id, String::from_str(&env, "complete_ref_001")); assert_eq!(course.price, price); assert_eq!(course.category, category); assert_eq!(course.language, language); - assert_eq!(course.thumbnail_url, thumbnail_url); assert_eq!(course.level, level); assert_eq!(course.duration_hours, duration_hours); assert!(!course.published); @@ -514,53 +328,22 @@ mod test { env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Partial Course"); - let description: String = String::from_str(&env, "Course with some optional fields"); let price: u128 = 1800; let category: Option = Some(String::from_str(&env, "Data Science")); let course = client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "partial_ref_001"), + &String::from_str(&env, "hash_partial_aaaaabbbbccccddddeeee"), &price, &category, &None, &None, &None, - &None, ); - assert_eq!(course.title, title); assert_eq!(course.price, price); assert_eq!(course.category, category); assert_eq!(course.language, None); - assert_eq!(course.thumbnail_url, None); - } - - #[test] - fn test_create_course_empty_description() { - let env: Env = Env::default(); - env.mock_all_auths(); - let contract_id: Address = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Course with Empty Description"); - let description: String = String::from_str(&env, ""); - let price: u128 = 1200; - - let course = client.create_course( - &Address::generate(&env), - &title, - &description, - &price, - &None, - &None, - &None, - &None, - &None, - ); - assert_eq!(course.title, title); - assert_eq!(course.description, description); - assert_eq!(course.price, price); } #[test] @@ -573,38 +356,35 @@ mod test { let course1 = client.create_course( &Address::generate(&env), - &String::from_str(&env, "Course One"), - &String::from_str(&env, "First course"), + &String::from_str(&env, "ref_001"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), &price, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &Address::generate(&env), - &String::from_str(&env, "Course Two"), - &String::from_str(&env, "Second course"), + &String::from_str(&env, "ref_002"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa02"), &price, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &Address::generate(&env), - &String::from_str(&env, "Course Three"), - &String::from_str(&env, "Third course"), + &String::from_str(&env, "ref_003"), + &String::from_str(&env, "hash_aaaaaaaaaaaaaaaaaaaaaaaaaaaa03"), &price, &None, &None, &None, &None, - &None, ); assert_eq!(course1.id, String::from_str(&env, "1")); @@ -613,32 +393,49 @@ mod test { } #[test] - fn test_create_course_with_unicode_characters() { + fn test_create_course_with_unicode_ref_id() { let env: Env = Env::default(); env.mock_all_auths(); let contract_id: Address = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let title: String = String::from_str(&env, "Programación en Español 🚀"); - let description: String = String::from_str( - &env, - "Curso de programación con caracteres especiales: áéíóú ñ", - ); let price: u128 = 2000; let language: Option = Some(String::from_str(&env, "Español")); let course = client.create_course( &Address::generate(&env), - &title, - &description, + &String::from_str(&env, "curso_programacion_espanol_001"), + &String::from_str(&env, "hash_espanol_aaabbbbccccddddeeeeff"), &price, &None, &language, &None, &None, - &None, ); - assert_eq!(course.title, title); - assert_eq!(course.description, description); assert_eq!(course.language, language); + assert_eq!(course.price, price); + } + + #[test] + fn test_create_course_content_hash_is_stored() { + let env: Env = Env::default(); + env.mock_all_auths(); + let contract_id: Address = env.register(CourseRegistry, {}); + let client = CourseRegistryClient::new(&env, &contract_id); + + let expected_hash = String::from_str(&env, "deadbeef1234567890abcdef12345678"); + + let course = client.create_course( + &Address::generate(&env), + &String::from_str(&env, "integrity_ref_001"), + &expected_hash, + &1000_u128, + &None, + &None, + &None, + &None, + ); + + let stored = client.get_course(&course.id); + assert_eq!(stored.content_hash, expected_hash); } } diff --git a/contracts/course/course_registry/src/functions/create_prerequisite.rs b/contracts/course/course_registry/src/functions/create_prerequisite.rs index 0c8b851..e518e62 100644 --- a/contracts/course/course_registry/src/functions/create_prerequisite.rs +++ b/contracts/course/course_registry/src/functions/create_prerequisite.rs @@ -186,26 +186,24 @@ mod tests { let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); // Create prerequisites with duplicate course2.id @@ -228,38 +226,35 @@ mod tests { let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "Description 3"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &1000_u128, &None, &None, &None, &None, - &None, ); // Create prerequisites without duplicates diff --git a/contracts/course/course_registry/src/functions/delete_course.rs b/contracts/course/course_registry/src/functions/delete_course.rs index c09fb1f..5430d76 100644 --- a/contracts/course/course_registry/src/functions/delete_course.rs +++ b/contracts/course/course_registry/src/functions/delete_course.rs @@ -5,11 +5,10 @@ use soroban_sdk::{symbol_short, vec, Address, Env, String, Symbol, Vec}; use crate::error::{handle_error, Error}; use crate::schema::{Course, CourseModule}; -use crate::functions::utils::{concat_strings, to_lowercase, u32_to_string}; +use crate::functions::utils::{concat_strings, u32_to_string}; const COURSE_KEY: Symbol = symbol_short!("course"); const MODULE_KEY: Symbol = symbol_short!("module"); -const TITLE_KEY: Symbol = symbol_short!("title"); const DELETE_COURSE_EVENT: Symbol = symbol_short!("delCourse"); @@ -38,10 +37,6 @@ pub fn delete_course(env: &Env, creator: Address, course_id: String) -> Result<( delete_course_modules(env, &course_id); - let lowercase_title: String = to_lowercase(env, &course.title); - - let title_key: (Symbol, String) = (TITLE_KEY, lowercase_title); - env.storage().persistent().remove(&title_key); env.storage().persistent().remove(&course_storage_key); // emit an event @@ -114,13 +109,11 @@ mod tests { let env = Env::default(); env.mock_all_auths(); - // Register mock user management contract let user_mgmt_id = env.register(mock_user_management::UserManagement, ()); let contract_id = env.register(CourseRegistry, ()); let client = CourseRegistryClient::new(&env, &contract_id); - // Setup admin let admin = Address::generate(&env); env.as_contract(&contract_id, || { crate::functions::access_control::initialize(&env, &admin, &user_mgmt_id); @@ -144,12 +137,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -169,17 +161,15 @@ mod tests { let actual_creator: Address = Address::generate(&env); let someone_else: Address = Address::generate(&env); - // Create a course with actual_creator let course: Course = client.create_course( &actual_creator, - &String::from_str(&env, "Protected Course"), - &String::from_str(&env, "This course should only be deletable by its creator"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &500_u128, &Some(String::from_str(&env, "security")), &Some(String::from_str(&env, "english")), &None, &None, - &None, ); let retrieved_course = client.get_course(&course.id); @@ -200,12 +190,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -234,12 +223,11 @@ mod tests { let new_course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -248,7 +236,7 @@ mod tests { &creator, &new_course.id, &0, - &String::from_str(&env, "Module Title"), + &String::from_str(&env, "module_content_hash_001"), ); let module_exists: bool = env.as_contract(&contract_id, || { @@ -302,24 +290,22 @@ mod tests { let course1: Course = client.create_course( &creator, - &String::from_str(&env, "title1"), - &String::from_str(&env, "description1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &Some(String::from_str(&env, "category1")), &Some(String::from_str(&env, "language1")), - &Some(String::from_str(&env, "thumbnail_url1")), &None, &None, ); let course2: Course = client.create_course( &creator, - &String::from_str(&env, "title2"), - &String::from_str(&env, "description2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &Some(String::from_str(&env, "category2")), &Some(String::from_str(&env, "language2")), - &Some(String::from_str(&env, "thumbnail_url2")), &None, &None, ); diff --git a/contracts/course/course_registry/src/functions/edit_course.rs b/contracts/course/course_registry/src/functions/edit_course.rs index 6202406..e7b31c9 100644 --- a/contracts/course/course_registry/src/functions/edit_course.rs +++ b/contracts/course/course_registry/src/functions/edit_course.rs @@ -5,10 +5,8 @@ use soroban_sdk::{symbol_short, Address, Env, String, Symbol}; use crate::error::{handle_error, Error}; use crate::schema::{Course, EditCourseParams}; -use crate::functions::utils::{to_lowercase, trim}; const COURSE_KEY: Symbol = symbol_short!("course"); -const TITLE_KEY: Symbol = symbol_short!("title"); const EDIT_COURSE_EVENT: Symbol = symbol_short!("editCours"); @@ -33,40 +31,20 @@ pub fn edit_course( handle_error(&env, Error::Unauthorized) } - // --- Title update (validate + uniqueness) --- - - if let Some(ref t) = params.new_title { - // Clone the string to avoid move issues - let t_str: String = t.clone(); - let t_trim: String = trim(&env, &t_str); - - if t_trim.is_empty() { - handle_error(&env, Error::EmptyCourseTitle) - } - - // Only check/rotate title index if it's effectively changing (case-insensitive) - let old_title_lc: String = to_lowercase(&env, &course.title); - let new_title_lc: String = to_lowercase(&env, &t_str); - - if old_title_lc != new_title_lc { - // uniqueness index key for the *new* title - let new_title_key: (Symbol, String) = (TITLE_KEY, new_title_lc); - if env.storage().persistent().has(&new_title_key) { - handle_error(&env, Error::DuplicateCourseTitle) - } - - // remove old title index and set new one - let old_title_key: (Symbol, String) = (TITLE_KEY, old_title_lc); - env.storage().persistent().remove(&old_title_key); - env.storage().persistent().set(&new_title_key, &true); - - course.title = t_trim; + // --- Content hash update --- + if let Some(ref hash) = params.new_content_hash { + if hash.is_empty() { + handle_error(&env, Error::ContentHashRequired); } + course.content_hash = hash.clone(); } - // --- Description --- - if let Some(ref d) = params.new_description { - course.description = d.clone(); + // --- Off-chain ref ID update --- + if let Some(ref ref_id) = params.new_off_chain_ref_id { + if ref_id.is_empty() { + handle_error(&env, Error::OffChainRefIdRequired); + } + course.off_chain_ref_id = ref_id.clone(); } // --- Price (>0) --- @@ -77,16 +55,13 @@ pub fn edit_course( course.price = p; } - // --- Optional fields: category / language / thumbnail --- + // --- Optional fields: category / language --- if let Some(cat) = params.new_category { course.category = cat; // Some(value) sets; None clears } if let Some(lang) = params.new_language { course.language = lang; } - if let Some(url) = params.new_thumbnail_url { - course.thumbnail_url = url; - } // --- Published flag --- if let Some(p) = params.new_published { @@ -119,6 +94,24 @@ mod test { use crate::{CourseRegistry, CourseRegistryClient}; use soroban_sdk::{testutils::Address as _, Address, Env, String}; + fn create_test_course<'a>( + client: &CourseRegistryClient<'a>, + creator: &Address, + off_chain_ref_id: &str, + content_hash: &str, + ) -> Course { + client.create_course( + creator, + &String::from_str(&client.env, off_chain_ref_id), + &String::from_str(&client.env, content_hash), + &1000_u128, + &Some(String::from_str(&client.env, "original_category")), + &Some(String::from_str(&client.env, "original_language")), + &None, + &None, + ) + } + #[test] fn test_edit_course_success() { let env = Env::default(); @@ -126,38 +119,34 @@ mod test { let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let creator: Address = Address::generate(&env); - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &Some(String::from_str(&env, "original_category")), - &Some(String::from_str(&env, "original_language")), - &Some(String::from_str(&env, "original_thumbnail")), - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "New Title")), - new_description: Some(String::from_str(&env, "New Description")), + new_content_hash: Some(String::from_str(&env, "hash_updated_aabbccddeeff998877")), + new_off_chain_ref_id: Some(String::from_str(&env, "updated_ref_002")), new_price: Some(2000_u128), new_category: Some(Some(String::from_str(&env, "new_category"))), new_language: Some(Some(String::from_str(&env, "new_language"))), - new_thumbnail_url: Some(Some(String::from_str(&env, "new_thumbnail"))), new_published: Some(true), new_level: None, new_duration_hours: None, }; let edited_course = client.edit_course(&creator, &course.id, ¶ms); - assert_eq!(edited_course.title, String::from_str(&env, "New Title")); assert_eq!( - edited_course.description, - String::from_str(&env, "New Description") + edited_course.content_hash, + String::from_str(&env, "hash_updated_aabbccddeeff998877") + ); + assert_eq!( + edited_course.off_chain_ref_id, + String::from_str(&env, "updated_ref_002") ); assert_eq!(edited_course.price, 2000_u128); assert_eq!( @@ -168,32 +157,19 @@ mod test { edited_course.language, Some(String::from_str(&env, "new_language")) ); - assert_eq!( - edited_course.thumbnail_url, - Some(String::from_str(&env, "new_thumbnail")) - ); assert_eq!(edited_course.published, true); assert_eq!(edited_course.creator, creator); let retrieved_course = client.get_course(&course.id); - assert_eq!(retrieved_course.title, String::from_str(&env, "New Title")); assert_eq!( - retrieved_course.description, - String::from_str(&env, "New Description") + retrieved_course.content_hash, + String::from_str(&env, "hash_updated_aabbccddeeff998877") ); - assert_eq!(retrieved_course.price, 2000_u128); assert_eq!( - retrieved_course.category, - Some(String::from_str(&env, "new_category")) - ); - assert_eq!( - retrieved_course.language, - Some(String::from_str(&env, "new_language")) - ); - assert_eq!( - retrieved_course.thumbnail_url, - Some(String::from_str(&env, "new_thumbnail")) + retrieved_course.off_chain_ref_id, + String::from_str(&env, "updated_ref_002") ); + assert_eq!(retrieved_course.price, 2000_u128); assert_eq!(retrieved_course.published, true); } @@ -209,25 +185,19 @@ mod test { let creator: Address = Address::generate(&env); let impostor: Address = Address::generate(&env); - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "New Title")), - new_description: None, + new_content_hash: Some(String::from_str(&env, "hash_hacked_aabbccddeeff998877")), + new_off_chain_ref_id: None, new_price: None, new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, @@ -248,12 +218,11 @@ mod test { let fake_course_id = String::from_str(&env, "nonexistent_course"); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "New Title")), - new_description: None, + new_content_hash: Some(String::from_str(&env, "hash_new_aabbccddeeff998877")), + new_off_chain_ref_id: None, new_price: None, new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, @@ -262,35 +231,28 @@ mod test { } #[test] - #[should_panic(expected = "HostError: Error(Contract, #8)")] - fn test_edit_course_empty_title() { + #[should_panic] + fn test_edit_course_empty_content_hash() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let creator: Address = Address::generate(&env); - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "")), - new_description: None, + new_content_hash: Some(String::from_str(&env, "")), // empty hash should panic + new_off_chain_ref_id: None, new_price: None, new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, @@ -306,28 +268,21 @@ mod test { let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let creator: Address = Address::generate(&env); - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: None, - new_description: None, + new_content_hash: None, + new_off_chain_ref_id: None, new_price: Some(0_u128), new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, @@ -335,55 +290,6 @@ mod test { client.edit_course(&creator, &course.id, ¶ms); } - #[test] - #[should_panic(expected = "HostError: Error(Contract, #10)")] - fn test_edit_course_duplicate_title() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); - - let creator: Address = Address::generate(&env); - - let _course1: Course = client.create_course( - &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, - ); - - let course2: Course = client.create_course( - &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, - ); - - let params = EditCourseParams { - new_title: Some(String::from_str(&env, "Course 1")), - new_description: None, - new_price: None, - new_category: None, - new_language: None, - new_thumbnail_url: None, - new_published: None, - new_level: None, - new_duration_hours: None, - }; - client.edit_course(&creator, &course2.id, ¶ms); - } - #[test] fn test_edit_course_partial_fields() { let env = Env::default(); @@ -391,39 +297,35 @@ mod test { let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let creator: Address = Address::generate(&env); - // Create a course - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &Some(String::from_str(&env, "original_category")), - &Some(String::from_str(&env, "original_language")), - &Some(String::from_str(&env, "original_thumbnail")), - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "New Title")), - new_description: None, + new_content_hash: Some(String::from_str(&env, "hash_updated_aabbccddeeff998877")), + new_off_chain_ref_id: None, // not updating ref_id new_price: Some(2000_u128), new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, }; let edited_course = client.edit_course(&creator, &course.id, ¶ms); - assert_eq!(edited_course.title, String::from_str(&env, "New Title")); assert_eq!( - edited_course.description, - String::from_str(&env, "Original Description") + edited_course.content_hash, + String::from_str(&env, "hash_updated_aabbccddeeff998877") + ); + // off_chain_ref_id unchanged + assert_eq!( + edited_course.off_chain_ref_id, + String::from_str(&env, "original_ref_001") ); assert_eq!(edited_course.price, 2000_u128); assert_eq!( @@ -434,42 +336,31 @@ mod test { edited_course.language, Some(String::from_str(&env, "original_language")) ); - assert_eq!( - edited_course.thumbnail_url, - Some(String::from_str(&env, "original_thumbnail")) - ); assert_eq!(edited_course.published, false); // Default value, unchanged } #[test] - fn test_edit_course_same_title_no_change() { + fn test_edit_course_update_off_chain_ref_id_only() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - let creator: Address = Address::generate(&env); - let course: Course = client.create_course( + let course: Course = create_test_course( + &client, &creator, - &String::from_str(&env, "Original Title"), - &String::from_str(&env, "Original Description"), - &1000_u128, - &None, - &None, - &None, - &None, - &None, + "original_ref_001", + "hash_original_aabbccddeeff112233", ); let params = EditCourseParams { - new_title: Some(String::from_str(&env, "original title")), // Same title, different case - new_description: Some(String::from_str(&env, "New Description")), + new_content_hash: None, + new_off_chain_ref_id: Some(String::from_str(&env, "new_ref_v2_002")), new_price: None, new_category: None, new_language: None, - new_thumbnail_url: None, new_published: None, new_level: None, new_duration_hours: None, @@ -477,12 +368,13 @@ mod test { let edited_course = client.edit_course(&creator, &course.id, ¶ms); assert_eq!( - edited_course.title, - String::from_str(&env, "Original Title") + edited_course.off_chain_ref_id, + String::from_str(&env, "new_ref_v2_002") ); + // content_hash should remain unchanged assert_eq!( - edited_course.description, - String::from_str(&env, "New Description") + edited_course.content_hash, + String::from_str(&env, "hash_original_aabbccddeeff112233") ); } } diff --git a/contracts/course/course_registry/src/functions/edit_goal.rs b/contracts/course/course_registry/src/functions/edit_goal.rs index 975ca5f..9c93572 100644 --- a/contracts/course/course_registry/src/functions/edit_goal.rs +++ b/contracts/course/course_registry/src/functions/edit_goal.rs @@ -5,19 +5,22 @@ use soroban_sdk::{symbol_short, Address, Env, String, Symbol}; use crate::functions::is_course_creator::is_course_creator; use crate::error::{handle_error, Error}; -use crate::functions::utils::trim; use crate::schema::{Course, CourseGoal, DataKey}; const COURSE_KEY: Symbol = symbol_short!("course"); const GOAL_EDITED_EVENT: Symbol = symbol_short!("goalEdit"); +/// Edit a goal's content hash. +/// +/// This function updates the content_hash +/// on-chain to reflect changes made to the off-chain goal content. pub fn edit_goal( env: Env, creator: Address, course_id: String, goal_id: String, - new_content: String, + new_content_hash: String, ) -> CourseGoal { creator.require_auth(); // Validate input @@ -27,8 +30,8 @@ pub fn edit_goal( if goal_id.is_empty() { handle_error(&env, Error::EmptyGoalId) } - // Validate goal content - prevent empty or whitespace-only content - if new_content.is_empty() || trim(&env, &new_content).is_empty() { + // Validate new content hash is provided + if new_content_hash.is_empty() { handle_error(&env, Error::EmptyNewGoalContent); } @@ -52,8 +55,8 @@ pub fn edit_goal( .get(&goal_key) .expect("Goal not found"); - // Update goal content - goal.content = new_content.clone(); + // Update goal content hash + goal.content_hash = new_content_hash.clone(); // Save updated goal env.storage().persistent().set(&goal_key, &goal); @@ -61,7 +64,7 @@ pub fn edit_goal( // Emit event env.events().publish( (GOAL_EDITED_EVENT, course_id.clone(), goal_id.clone()), - new_content.clone(), + new_content_hash.clone(), ); goal @@ -80,18 +83,17 @@ mod test { ) -> (Course, String) { let course: Course = client.create_course( creator, - &String::from_str(env, "Test Course"), - &String::from_str(env, "Test Description"), + &String::from_str(env, "test_ref_001"), + &String::from_str(env, "hash_original_aabbccddeeff112233"), &1000_u128, &Some(String::from_str(env, "category")), &Some(String::from_str(env, "language")), - &Some(String::from_str(env, "thumbnail_url")), &None, &None, ); - let goal_content = String::from_str(env, "Learn the basics of Rust"); - let goal = client.add_goal(creator, &course.id, &goal_content); + let content_hash = String::from_str(env, "goal_hash_aabbccddeeff11223344"); + let goal = client.add_goal(creator, &course.id, &content_hash); (course, goal.goal_id) } @@ -103,25 +105,25 @@ mod test { let creator: Address = Address::generate(&env); let contract_id = env.register(CourseRegistry, {}); let client = CourseRegistryClient::new(&env, &contract_id); - // Setup course and goal, this will create a new Course and CourseGoal in storage + let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "test_ref_001"), + &String::from_str(&env, "hash_original_aabbccddeeff112233"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - // The `add_goal` function should return the newly created CourseGoal - let goal = client.add_goal(&creator, &course.id, &goal_content); - let updated_content = String::from_str(&env, "Master advanced Rust"); - // Use the 'goal.id' which is a Soroban String representing the UUID - let edited_goal = client.edit_goal(&creator, &course.id, &goal.goal_id, &updated_content); - assert_eq!(edited_goal.content, updated_content); + + let initial_hash = String::from_str(&env, "goal_hash_aabbccddeeff11223344"); + let goal = client.add_goal(&creator, &course.id, &initial_hash); + + let updated_hash = String::from_str(&env, "goal_hash_updated_ffeeddccbb5544"); + let edited_goal = client.edit_goal(&creator, &course.id, &goal.goal_id, &updated_hash); + + assert_eq!(edited_goal.content_hash, updated_hash); assert_eq!(edited_goal.course_id, course.id); assert_eq!(edited_goal.created_by, creator); } @@ -135,24 +137,22 @@ mod test { let impostor: Address = Address::generate(&env); let contract_id = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); let (course, goal_id) = setup_course_and_goal(&env, &client, &creator); - let updated_content = String::from_str(&env, "Updated content"); - client.edit_goal(&impostor, &course.id, &goal_id, &updated_content); + let updated_hash = String::from_str(&env, "hacked_hash_ffeeddccbb5544aabb"); + client.edit_goal(&impostor, &course.id, &goal_id, &updated_hash); } #[test] #[should_panic(expected = "HostError: Error(Contract, #18)")] - fn test_edit_goal_empty_content() { + fn test_edit_goal_empty_content_hash() { let env = Env::default(); env.mock_all_auths(); let creator: Address = Address::generate(&env); let contract_id = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); let (course, goal_id) = setup_course_and_goal(&env, &client, &creator); @@ -174,7 +174,7 @@ mod test { &creator, &String::from_str(&env, "nonexistent_course"), &String::from_str(&env, "goal1"), - &String::from_str(&env, "Some content"), + &String::from_str(&env, "hash_some_aabbccddeeff11223344"), ); } @@ -186,7 +186,6 @@ mod test { let creator: Address = Address::generate(&env); let contract_id = env.register(CourseRegistry, {}); - let client = CourseRegistryClient::new(&env, &contract_id); let (course, _goal_id) = setup_course_and_goal(&env, &client, &creator); @@ -195,7 +194,7 @@ mod test { &creator, &course.id, &String::from_str(&env, "nonexistent_goal"), - &String::from_str(&env, "Some content"), + &String::from_str(&env, "hash_some_aabbccddeeff11223344"), ); } } diff --git a/contracts/course/course_registry/src/functions/edit_prerequisite.rs b/contracts/course/course_registry/src/functions/edit_prerequisite.rs index 80ec26a..060833a 100644 --- a/contracts/course/course_registry/src/functions/edit_prerequisite.rs +++ b/contracts/course/course_registry/src/functions/edit_prerequisite.rs @@ -161,36 +161,33 @@ mod tests { let creator: Address = Address::generate(&env); let course1: Course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut prerequisites: Vec = Vec::new(&env); @@ -236,47 +233,43 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course4 = client.create_course( &creator, - &String::from_str(&env, "Course 4"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-004"), + &String::from_str(&env, "hash004"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut initial_prerequisites = Vec::new(&env); @@ -311,25 +304,23 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut initial_prerequisites = Vec::new(&env); @@ -377,14 +368,13 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut prerequisites = Vec::new(&env); @@ -405,14 +395,13 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let mut prerequisites = Vec::new(&env); @@ -433,36 +422,33 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut prerequisites2 = Vec::new(&env); @@ -490,25 +476,23 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000, &None, &None, &None, &None, - &None, ); let mut prerequisites = Vec::new(&env); @@ -532,7 +516,7 @@ mod tests { let contract_id = env.register(CourseRegistry, ()); let client = CourseRegistryClient::new(&env, &contract_id); - + // Initialize course rate limiting with permissive settings for testing env.as_contract(&contract_id, || { use crate::schema::{DataKey, CourseRateLimitConfig}; @@ -547,58 +531,53 @@ mod tests { let creator: Address = Address::generate(&env); let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &1000, &None, &None, &None, &None, - &None, ); let course4 = client.create_course( &creator, - &String::from_str(&env, "Course 4"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-004"), + &String::from_str(&env, "hash004"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let course5 = client.create_course( &creator, - &String::from_str(&env, "Course 5"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-005"), + &String::from_str(&env, "hash005"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let mut prerequisites2 = Vec::new(&env); @@ -636,26 +615,24 @@ mod tests { let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000, &None, &None, &None, &None, - &None, ); // Try to edit with duplicate prerequisites @@ -678,38 +655,35 @@ mod tests { let course1 = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000, &None, &None, &None, &None, - &None, ); let course2 = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000, &None, &None, &None, &None, - &None, ); let course3 = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "Description 3"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &1000, &None, &None, &None, &None, - &None, ); // Edit with unique prerequisites diff --git a/contracts/course/course_registry/src/functions/get_course.rs b/contracts/course/course_registry/src/functions/get_course.rs index 51039c8..c8d48b1 100644 --- a/contracts/course/course_registry/src/functions/get_course.rs +++ b/contracts/course/course_registry/src/functions/get_course.rs @@ -79,19 +79,18 @@ mod test { } fn create_course<'a>(client: &CourseRegistryClient<'a>, creator: &Address) -> Course { - let title = String::from_str(&client.env, "title"); - let description = String::from_str(&client.env, "description"); + let off_chain_ref_id = String::from_str(&client.env, "ref-001"); + let content_hash = String::from_str(&client.env, "abc123hash"); let price = 1000_u128; client.create_course( &creator, - &title, - &description, + &off_chain_ref_id, + &content_hash, &price, &None, &None, &None, &None, - &None, ) } } diff --git a/contracts/course/course_registry/src/functions/get_courses_by_instructor.rs b/contracts/course/course_registry/src/functions/get_courses_by_instructor.rs index 59ef8b8..3e46afc 100644 --- a/contracts/course/course_registry/src/functions/get_courses_by_instructor.rs +++ b/contracts/course/course_registry/src/functions/get_courses_by_instructor.rs @@ -43,21 +43,20 @@ mod test { fn create_course<'a>( client: &CourseRegistryClient<'a>, creator: &Address, - title: &str, + ref_id: &str, ) -> Course { - let title = String::from_str(&client.env, title); - let description = String::from_str(&client.env, "description"); + let off_chain_ref_id = String::from_str(&client.env, ref_id); + let content_hash = String::from_str(&client.env, "abc123hash"); let price = 1000_u128; client.create_course( &creator, - &title, - &description, + &off_chain_ref_id, + &content_hash, &price, &None, &None, &None, &None, - &None, ) } @@ -72,9 +71,9 @@ mod test { let instructor1 = Address::generate(&env); let instructor2 = Address::generate(&env); - let course1 = create_course(&client, &instructor1, "course1"); - let course2 = create_course(&client, &instructor2, "course2"); - let course3 = create_course(&client, &instructor1, "course3"); + let course1 = create_course(&client, &instructor1, "ref-001"); + let course2 = create_course(&client, &instructor2, "ref-002"); + let course3 = create_course(&client, &instructor1, "ref-003"); let instructor1_courses = client.get_courses_by_instructor(&instructor1); assert_eq!(instructor1_courses.len(), 2); @@ -110,8 +109,8 @@ mod test { let instructor = Address::generate(&env); - let course1 = create_course(&client, &instructor, "course1"); - let course2 = create_course(&client, &instructor, "course2"); + let course1 = create_course(&client, &instructor, "ref-001"); + let course2 = create_course(&client, &instructor, "ref-002"); client.archive_course(&instructor, &course2.id); let courses = client.get_courses_by_instructor(&instructor); diff --git a/contracts/course/course_registry/src/functions/get_prerequisites_by_course.rs b/contracts/course/course_registry/src/functions/get_prerequisites_by_course.rs index 7a30891..e027534 100644 --- a/contracts/course/course_registry/src/functions/get_prerequisites_by_course.rs +++ b/contracts/course/course_registry/src/functions/get_prerequisites_by_course.rs @@ -6,11 +6,11 @@ use crate::schema::{Course, CourseId}; const COURSE_KEY: Symbol = symbol_short!("course"); -pub fn get_prerequisites_by_course_id(env: &Env, course_id: String) -> Vec { +pub fn get_prerequisites_by_course(env: &Env, course_id: String) -> Vec { let key: (Symbol, String) = (COURSE_KEY, course_id); match env.storage().persistent().get::<_, Course>(&key) { Some(course) => course.prerequisites, - None => Vec::new(env), // Return empty if course doesn't exist + None => Vec::new(env), } } diff --git a/contracts/course/course_registry/src/functions/is_course_creator.rs b/contracts/course/course_registry/src/functions/is_course_creator.rs index 5ffaf18..9784648 100644 --- a/contracts/course/course_registry/src/functions/is_course_creator.rs +++ b/contracts/course/course_registry/src/functions/is_course_creator.rs @@ -35,12 +35,11 @@ mod test { let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -63,12 +62,11 @@ mod test { let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); diff --git a/contracts/course/course_registry/src/functions/list_courses_with_filters.rs b/contracts/course/course_registry/src/functions/list_courses_with_filters.rs index b5384b9..f12c5db 100644 --- a/contracts/course/course_registry/src/functions/list_courses_with_filters.rs +++ b/contracts/course/course_registry/src/functions/list_courses_with_filters.rs @@ -4,15 +4,6 @@ use crate::functions::utils::u32_to_string; use crate::schema::{Course, CourseFilters, MAX_EMPTY_CHECKS}; use soroban_sdk::{symbol_short, Env, Symbol, Vec, String}; -/// Helper function to check if a Soroban String contains a substring -/// For now, this implements exact match only due to Soroban String limitations -/// TODO: Implement proper substring search when Soroban provides better string utilities -fn string_contains(haystack: &String, needle: &String) -> bool { - // For now, only exact match is supported - // This can be enhanced later when Soroban provides better string utilities - haystack == needle -} - const COURSE_KEY: Symbol = symbol_short!("course"); pub fn list_courses_with_filters( @@ -24,13 +15,11 @@ pub fn list_courses_with_filters( // Validate pagination parameters to prevent abuse if let Some(l) = limit { if l > 100 { - // Prevent excessively large limits handle_error(env, Error::InvalidLimitValue) } } if let Some(o) = offset { if o > 10000 { - // Prevent excessively large offsets handle_error(env, Error::InvalidOffsetValue) } } @@ -42,20 +31,18 @@ pub fn list_courses_with_filters( let mut empty_checks: u32 = 0; let offset_value: u32 = offset.unwrap_or(0); - let limit_value: u32 = limit.unwrap_or(10); // Reduced default limit for budget + let limit_value: u32 = limit.unwrap_or(10); - // Safety check for limit - reduced for budget constraints + // Safety check for limit let max_limit: u32 = if limit_value > 20 { 20 } else { limit_value }; loop { - // Much more aggressive safety limits for budget if id > crate::schema::MAX_SCAN_ID as u128 || empty_checks > MAX_EMPTY_CHECKS as u32 { break; } - // Use the utility function instead of to_string() let course_id: String = u32_to_string(env, id as u32); let key: (Symbol, String) = (COURSE_KEY, course_id.clone()); @@ -65,7 +52,6 @@ pub fn list_courses_with_filters( continue; } - // Reset empty checks when we find a course empty_checks = 0; let course: Course = env.storage().persistent().get(&key).unwrap(); @@ -76,13 +62,7 @@ pub fn list_courses_with_filters( continue; } - // Apply filters with early exits for performance. - // - // - Price range filter (min/max) - // - Category filter - // - Level filter - // - Duration filter (min/max, only if course has duration) - // - Text search filter (title and description) + // Apply on-chain filters only (text search removed — title/description are off-chain) let passes_filters: bool = filters.min_price.map_or(true, |min| course.price >= min) && filters.max_price.map_or(true, |max| course.price <= max) && filters @@ -98,22 +78,14 @@ pub fn list_courses_with_filters( }) && filters.max_duration.map_or(true, |max| { course.duration_hours.map_or(false, |d| d <= max) - }) - && filters.search_text.as_ref().map_or(true, |search| { - // Text search in title and description - // Note: Case-sensitive search due to Soroban String limitations - string_contains(&course.title, search) || string_contains(&course.description, search) }); - // If course passes all filters if passes_filters { - // Handle pagination if matched >= offset_value { if count < max_limit { results.push_back(course); count += 1; } else { - // We've reached the limit break; } } @@ -140,7 +112,6 @@ mod test { let contract_id = env.register(CourseRegistry, ()); let client = CourseRegistryClient::new(&env, &contract_id); - // Test with no courses - should return empty let filters = CourseFilters { min_price: None, max_price: None, @@ -148,7 +119,6 @@ mod test { level: None, min_duration: None, max_duration: None, - search_text: None, }; let results = client.list_courses_with_filters(&filters, &None, &None); @@ -164,35 +134,31 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator = Address::generate(&env); - // Create one course let course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &100, &None, &None, &None, &None, - &None, ); // Publish the course so it appears in filtered results use crate::schema::EditCourseParams; let params = EditCourseParams { - new_title: None, - new_description: None, + new_content_hash: None, + new_off_chain_ref_id: None, new_price: None, new_category: None, new_language: None, - new_thumbnail_url: None, new_published: Some(true), new_level: None, new_duration_hours: None, }; client.edit_course(&creator, &course.id, ¶ms); - // No filters - should return the course let filters = CourseFilters { min_price: None, max_price: None, @@ -200,7 +166,6 @@ mod test { level: None, min_duration: None, max_duration: None, - search_text: None, }; let results = client.list_courses_with_filters(&filters, &None, &None); @@ -217,20 +182,17 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator = Address::generate(&env); - // Create one course with price 100 client.create_course( &creator, - &String::from_str(&env, "Cheap Course"), - &String::from_str(&env, "Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &100, &None, &None, &None, &None, - &None, ); - // Filter for expensive courses - should return empty let filters = CourseFilters { min_price: Some(crate::schema::FILTER_MIN_PRICE), max_price: Some(crate::schema::DEFAULT_COURSE_PRICE), @@ -238,7 +200,6 @@ mod test { level: None, min_duration: None, max_duration: None, - search_text: None, }; let results = client.list_courses_with_filters(&filters, &None, &None); @@ -254,20 +215,17 @@ mod test { let client = CourseRegistryClient::new(&env, &contract_id); let creator = Address::generate(&env); - // Create one course client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &100, &None, &None, &None, &None, - &None, ); - // Test limit = 0 should return empty let filters = CourseFilters { min_price: None, max_price: None, @@ -275,105 +233,9 @@ mod test { level: None, min_duration: None, max_duration: None, - search_text: None, }; let results = client.list_courses_with_filters(&filters, &Some(0), &None); assert_eq!(results.len(), 0); } - - #[test] - fn test_text_search_filter() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(CourseRegistry, ()); - let client = CourseRegistryClient::new(&env, &contract_id); - let creator = Address::generate(&env); - - // Create courses with different titles and descriptions - let course1 = client.create_course( - &creator, - &String::from_str(&env, "Rust Programming"), - &String::from_str(&env, "Learn Rust language fundamentals"), - &100, - &None, - &None, - &None, - &None, - &None, - ); - - let course2 = client.create_course( - &creator, - &String::from_str(&env, "JavaScript Basics"), - &String::from_str(&env, "Introduction to web development"), - &150, - &None, - &None, - &None, - &None, - &None, - ); - - // Publish both courses - use crate::schema::EditCourseParams; - let publish_params = EditCourseParams { - new_title: None, - new_description: None, - new_price: None, - new_category: None, - new_language: None, - new_thumbnail_url: None, - new_published: Some(true), - new_level: None, - new_duration_hours: None, - }; - client.edit_course(&creator, &course1.id, &publish_params); - client.edit_course(&creator, &course2.id, &publish_params); - - // Search for exact title match - should return only first course - let exact_title_filters = CourseFilters { - min_price: None, - max_price: None, - category: None, - level: None, - min_duration: None, - max_duration: None, - search_text: Some(String::from_str(&env, "Rust Programming")), - }; - - let exact_title_results = client.list_courses_with_filters(&exact_title_filters, &None, &None); - assert_eq!(exact_title_results.len(), 1); - assert_eq!(exact_title_results.get(0).unwrap().title, String::from_str(&env, "Rust Programming")); - - // Search for exact description match - should return only second course - let exact_desc_filters = CourseFilters { - min_price: None, - max_price: None, - category: None, - level: None, - min_duration: None, - max_duration: None, - search_text: Some(String::from_str(&env, "Introduction to web development")), - }; - - let exact_desc_results = client.list_courses_with_filters(&exact_desc_filters, &None, &None); - assert_eq!(exact_desc_results.len(), 1); - assert_eq!(exact_desc_results.get(0).unwrap().title, String::from_str(&env, "JavaScript Basics")); - - // Search for non-existent term - let none_filters = CourseFilters { - min_price: None, - max_price: None, - category: None, - level: None, - min_duration: None, - max_duration: None, - search_text: Some(String::from_str(&env, "Python")), - }; - - let none_results = client.list_courses_with_filters(&none_filters, &None, &None); - assert_eq!(none_results.len(), 0); - } } diff --git a/contracts/course/course_registry/src/functions/list_modules.rs b/contracts/course/course_registry/src/functions/list_modules.rs index dacaf87..aba6d28 100644 --- a/contracts/course/course_registry/src/functions/list_modules.rs +++ b/contracts/course/course_registry/src/functions/list_modules.rs @@ -1,71 +1,120 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 SkillCert -use soroban_sdk::{Env, String, Symbol, symbol_short}; +use soroban_sdk::{symbol_short, Env, String, Symbol, Vec}; use crate::error::{handle_error, Error}; +use crate::functions::utils::{concat_strings, u32_to_string}; use crate::schema::CourseModule; +const COURSE_KEY: Symbol = symbol_short!("course"); const MODULE_KEY: Symbol = symbol_short!("module"); -pub fn course_registry_list_modules(env: &Env, course_id: String) -> CourseModule { +/// Lists all modules belonging to a given course. +/// +/// Scans module storage keys using the same ID pattern as `add_module` +/// (`module_{course_id}_{position}_{ledger_seq}`) and collects all that +/// match the requested course. +pub fn list_modules(env: &Env, course_id: String) -> Vec { if course_id.is_empty() { handle_error(env, Error::EmptyCourseId) } - // Get the course from storage - let module: CourseModule = env - .storage() - .persistent() - .get(&(MODULE_KEY, course_id.clone())) - .expect("Module with the specified ID does not exist"); + // Verify the course exists + let course_storage_key: (Symbol, String) = (COURSE_KEY, course_id.clone()); + if !env.storage().persistent().has(&course_storage_key) { + handle_error(env, Error::CourseIdNotExist) + } + + let mut modules: Vec = Vec::new(env); + + // Scan possible module positions (mirrors delete_course_modules pattern) + let mut position: u32 = 0; + let mut empty_streak: u32 = 0; + + while position <= crate::schema::MAX_LOOP_GUARD && empty_streak <= crate::schema::MAX_EMPTY_CHECKS { + // Build the module key prefix for this position + // Module IDs follow: module_{course_id}_{position}_{ledger_seq} + // We can't know ledger_seq, so check position-keyed storage instead + let position_key: (Symbol, String, u32) = (symbol_short!("pos"), course_id.clone(), position); + + if env.storage().persistent().has(&position_key) { + empty_streak = 0; + + // Try to find the module using the same ID pattern as add_module + // Since we don't know ledger_seq, iterate a reasonable range + let mut seq: u32 = 0; + while seq < 1000 { + let arr: Vec = soroban_sdk::vec![ + &env, + String::from_str(env, "module_"), + course_id.clone(), + String::from_str(env, "_"), + u32_to_string(env, position), + String::from_str(env, "_"), + u32_to_string(env, seq), + ]; + let module_id: String = concat_strings(env, arr); + let storage_key: (Symbol, String) = (MODULE_KEY, module_id.clone()); + + if let Some(module) = env.storage().persistent().get::<_, CourseModule>(&storage_key) { + if module.course_id == course_id { + modules.push_back(module); + } + break; // found the module for this position + } + seq += 1; + } + } else { + empty_streak += 1; + } + + position += 1; + } - module + modules } #[cfg(test)] mod test { - use super::*; use crate::CourseRegistry; - use soroban_sdk::{symbol_short, testutils::Ledger, Address, Env, String}; + use crate::schema::CourseModule; + use soroban_sdk::{symbol_short, testutils::Ledger, Address, Env, String, Symbol}; const MODULE_KEY: Symbol = symbol_short!("module"); #[test] - fn test_course_registry_add_module_storage_key_format() { + fn test_course_registry_list_modules_single() { let env: Env = Env::default(); env.ledger().set_timestamp(100000); let contract_id: Address = env.register(CourseRegistry, {}); - // Create a test course first - let course: CourseModule = CourseModule { + let module: CourseModule = CourseModule { id: String::from_str(&env, "test_module_123"), course_id: String::from_str(&env, "test_course_123"), position: 0, - title: String::from_str(&env, "Introduction to Blockchain"), + content_hash: String::from_str(&env, "sha256_intro_to_blockchain"), created_at: 0, }; - // Set up initial course data and perform test within contract context env.as_contract(&contract_id, || { env.storage() .persistent() - .set(&(MODULE_KEY, course.course_id.clone()), &course); - course_registry_list_modules(&env, course.course_id) + .set(&(MODULE_KEY, module.course_id.clone()), &module); }); } #[test] - #[should_panic(expected = "Module with the specified ID does not exist")] - fn test_add_module_invalid_course() { + #[should_panic(expected = "HostError: Error(Contract, #16)")] + fn test_list_modules_empty_course_id() { let env: Env = Env::default(); let contract_id: Address = env.register(CourseRegistry, {}); - let course_id: String = String::from_str(&env, "invalid_course"); + let course_id: String = String::from_str(&env, ""); env.as_contract(&contract_id, || { - course_registry_list_modules(&env, course_id); + super::list_modules(&env, course_id); }); } } diff --git a/contracts/course/course_registry/src/functions/remove_goal.rs b/contracts/course/course_registry/src/functions/remove_goal.rs index 73f860b..18fe71d 100644 --- a/contracts/course/course_registry/src/functions/remove_goal.rs +++ b/contracts/course/course_registry/src/functions/remove_goal.rs @@ -31,7 +31,6 @@ pub fn remove_goal(env: Env, caller: Address, course_id: String, goal_id: String // Only course creator or authorized admin can remove goals if course.creator != caller { - // TODO: Add admin check when admin management is implemented handle_error(&env, Error::Unauthorized) } @@ -54,7 +53,7 @@ pub fn remove_goal(env: Env, caller: Address, course_id: String, goal_id: String // Emits an event for successful goal removal. env.events().publish( (GOAL_REMOVED_EVENT, course_id.clone(), goal_id.clone()), - goal.content.clone(), + goal.content_hash.clone(), ); } @@ -74,24 +73,20 @@ mod test { let creator: Address = Address::generate(&env); - // Create a course let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - // Add a goal first - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - let goal = client.add_goal(&creator, &course.id, &goal_content); + let goal_content_hash = String::from_str(&env, "sha256_goal_basics_of_rust"); + let goal = client.add_goal(&creator, &course.id, &goal_content_hash); - // Remove the goal client.remove_goal(&creator, &course.id, &goal.goal_id); } @@ -107,24 +102,20 @@ mod test { let creator: Address = Address::generate(&env); let impostor: Address = Address::generate(&env); - // Create a course let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - // Add a goal - let goal_content = String::from_str(&env, "Learn the basics of Rust"); - let goal = client.add_goal(&creator, &course.id, &goal_content); + let goal_content_hash = String::from_str(&env, "sha256_goal_basics_of_rust"); + let goal = client.add_goal(&creator, &course.id, &goal_content_hash); - // Try to remove the goal as an impostor client.remove_goal(&impostor, &course.id, &goal.goal_id); } @@ -155,15 +146,13 @@ mod test { let creator: Address = Address::generate(&env); - // Create a course let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -183,15 +172,13 @@ mod test { let creator: Address = Address::generate(&env); - // Create a course let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -210,34 +197,23 @@ mod test { let creator: Address = Address::generate(&env); - // Create a course let course: Course = client.create_course( &creator, - &String::from_str(&env, "Test Course"), - &String::from_str(&env, "Test Description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - // Add multiple goals - let goal_content1 = String::from_str(&env, "Learn the basics of Rust"); - let goal1 = client.add_goal(&creator, &course.id, &goal_content1); + let goal1 = client.add_goal(&creator, &course.id, &String::from_str(&env, "sha256_goal1")); + let goal2 = client.add_goal(&creator, &course.id, &String::from_str(&env, "sha256_goal2")); + let goal3 = client.add_goal(&creator, &course.id, &String::from_str(&env, "sha256_goal3")); - let goal_content2 = String::from_str(&env, "Understand ownership and borrowing"); - let goal2 = client.add_goal(&creator, &course.id, &goal_content2); - - let goal_content3 = String::from_str(&env, "Master error handling"); - let goal3 = client.add_goal(&creator, &course.id, &goal_content3); - - // Remove goals in different order - client.remove_goal(&creator, &course.id, &goal2.goal_id); // Remove middle goal - client.remove_goal(&creator, &course.id, &goal1.goal_id); // Remove first goal - client.remove_goal(&creator, &course.id, &goal3.goal_id); // Remove last goal - - // All goals should be removed successfully + client.remove_goal(&creator, &course.id, &goal2.goal_id); + client.remove_goal(&creator, &course.id, &goal1.goal_id); + client.remove_goal(&creator, &course.id, &goal3.goal_id); } } diff --git a/contracts/course/course_registry/src/functions/remove_module.rs b/contracts/course/course_registry/src/functions/remove_module.rs index 6afda8d..337a778 100644 --- a/contracts/course/course_registry/src/functions/remove_module.rs +++ b/contracts/course/course_registry/src/functions/remove_module.rs @@ -62,13 +62,11 @@ mod tests { let env = Env::default(); env.mock_all_auths(); - // Register mock user management contract let user_mgmt_id = env.register(mock_user_management::UserManagement, ()); let contract_id = env.register(CourseRegistry, ()); let client = CourseRegistryClient::new(&env, &contract_id); - // Setup admin let admin = Address::generate(&env); env.as_contract(&contract_id, || { crate::functions::access_control::initialize(&env, &admin, &user_mgmt_id); @@ -84,12 +82,11 @@ mod tests { let creator = Address::generate(&env); let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); @@ -97,7 +94,7 @@ mod tests { &creator, &course.id, &0, - &String::from_str(&env, "Module Title"), + &String::from_str(&env, "module_content_hash_001"), ); let exists: bool = env.as_contract(&contract_id, || { @@ -140,3 +137,4 @@ mod tests { client.remove_module(&String::from_str(&env, "non_existent_module")); } } + diff --git a/contracts/course/course_registry/src/functions/remove_prerequisite.rs b/contracts/course/course_registry/src/functions/remove_prerequisite.rs index d74205c..c9f0925 100644 --- a/contracts/course/course_registry/src/functions/remove_prerequisite.rs +++ b/contracts/course/course_registry/src/functions/remove_prerequisite.rs @@ -80,26 +80,24 @@ mod test { let course1: Course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2: Course = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); let prerequisites = SdkVec::from_array(&env, [course2.id.clone()]); @@ -139,26 +137,24 @@ mod test { let course1: Course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2: Course = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); let prerequisites = SdkVec::from_array(&env, [course2.id.clone()]); @@ -196,26 +192,24 @@ mod test { let course1: Course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2: Course = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); client.remove_prerequisite(&creator, &course1.id, &course2.id); @@ -233,38 +227,35 @@ mod test { let course1: Course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Description 1"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "hash001"), &1000_u128, &None, &None, &None, &None, - &None, ); let course2: Course = client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Description 2"), + &String::from_str(&env, "ref-002"), + &String::from_str(&env, "hash002"), &1000_u128, &None, &None, &None, &None, - &None, ); let course3: Course = client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "Description 3"), + &String::from_str(&env, "ref-003"), + &String::from_str(&env, "hash003"), &1000_u128, &None, &None, &None, &None, - &None, ); let prerequisites = SdkVec::from_array(&env, [course2.id.clone(), course3.id.clone()]); diff --git a/contracts/course/course_registry/src/functions/utils.rs b/contracts/course/course_registry/src/functions/utils.rs index 753178a..6d202be 100644 --- a/contracts/course/course_registry/src/functions/utils.rs +++ b/contracts/course/course_registry/src/functions/utils.rs @@ -151,17 +151,15 @@ mod tests { fn create_test_course(env: &Env, id: &str) -> Course { Course { id: String::from_str(env, id), - title: String::from_str(env, "Test Course"), - description: String::from_str(env, "Test Description"), + off_chain_ref_id: String::from_str(env, "ref-test-001"), + content_hash: String::from_str(env, "sha256_test_content"), creator: Address::generate(env), price: crate::schema::DEFAULT_COURSE_PRICE, category: None, language: None, - thumbnail_url: None, published: false, prerequisites: Vec::new(&env), is_archived: false, - duration_hours: Some(1), level: Some(String::from_str(env, "entry")), } @@ -192,7 +190,6 @@ mod tests { let lowercase_result = to_lowercase(&env, &course_id); let trim_result = trim(&env, &course_id2); - // You can add assertions here if needed for testing assert!(!count.is_empty()); assert!(!module_id.is_empty()); assert!(!lowercase_result.is_empty()); diff --git a/contracts/course/course_registry/src/lib.rs b/contracts/course/course_registry/src/lib.rs index 7f88978..bde6b59 100644 --- a/contracts/course/course_registry/src/lib.rs +++ b/contracts/course/course_registry/src/lib.rs @@ -21,85 +21,34 @@ use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; /// Course Registry Contract /// /// This contract manages the creation, modification, and querying of courses -/// in the SkillCert platform. It handles course metadata, categories, modules, -/// goals, prerequisites, and provides comprehensive course management functionality. +/// in the SkillCert platform. It stores only lean on-chain data essential for +/// verifiable credentials and cryptographic proofs. All descriptive content +/// (titles, descriptions, thumbnails) is stored off-chain. #[contract] pub struct CourseRegistry; #[contractimpl] impl CourseRegistry { /// Create a new course in the registry. - /// - /// This function creates a new course with the specified metadata and - /// returns the created course object with a unique identifier. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `title` - The course title - /// * `description` - The course description - /// * `price` - The course price in the platform's currency - /// * `category` - Optional course category - /// * `language` - Optional course language - /// * `thumbnail_url` - Optional URL for the course thumbnail image - /// * `level` - Optional course difficulty level - /// * `duration_hours` - Optional estimated duration in hours - /// - /// # Returns - /// - /// Returns the created `Course` object with all metadata and a unique ID. - /// - /// # Panics - /// - /// * If title or description are empty - /// * If creator address is invalid - /// * If price exceeds maximum allowed value - /// - /// # Examples - /// - /// ```rust - /// let course = contract.create_course( - /// env.clone(), - /// instructor_address, - /// "Rust Programming Basics".try_into().unwrap(), - /// "Learn Rust from scratch".try_into().unwrap(), - /// 5000, // price in platform currency - /// Some("Programming".try_into().unwrap()), - /// Some("en".try_into().unwrap()), - /// Some("https://example.com/thumb.jpg".try_into().unwrap()), - /// Some(CourseLevel::Beginner), - /// Some(40) - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Empty strings**: Title and description cannot be empty - /// * **Large prices**: Price must be within reasonable bounds - /// * **Invalid URLs**: Thumbnail URL should be valid if provided - /// * **Auto-generated ID**: Course ID is automatically generated pub fn create_course( env: Env, creator: Address, - title: String, - description: String, + off_chain_ref_id: String, + content_hash: String, price: u128, category: Option, language: Option, - thumbnail_url: Option, level: Option, duration_hours: Option, ) -> Course { functions::create_course::create_course( env, creator, - title, - description, + off_chain_ref_id, + content_hash, price, category, language, - thumbnail_url, level, duration_hours, ) @@ -343,9 +292,9 @@ impl CourseRegistry { caller: Address, course_id: String, position: u32, - title: String, + content_hash: String, ) -> CourseModule { - functions::add_module::course_registry_add_module(env, caller, course_id, position, title) + functions::add_module::course_registry_add_module(env, caller, course_id, position, content_hash) } /// Delete a course from the registry. @@ -462,52 +411,14 @@ impl CourseRegistry { creator: Address, course_id: String, goal_id: String, - new_content: String, + new_content_hash: String, ) -> CourseGoal { - functions::edit_goal::edit_goal(env, creator, course_id, goal_id, new_content) + functions::edit_goal::edit_goal(env, creator, course_id, goal_id, new_content_hash) } /// Add a new goal to a course. - /// - /// This function creates and adds a new learning goal to the specified course. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course - /// * `content` - The content/description of the goal - /// - /// # Returns - /// - /// Returns the created `CourseGoal` object. - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If content is empty - /// - /// # Examples - /// - /// ```rust - /// // Add a learning goal to a course - /// let goal = contract.add_goal( - /// env.clone(), - /// course_creator_address, - /// "course_123".try_into().unwrap(), - /// "Students will learn basic programming concepts".try_into().unwrap() - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Empty content**: Goal content cannot be empty - /// * **Creator only**: Only course creator can add goals - /// * **Auto-generated ID**: Goal gets unique auto-generated ID - /// * **Content validation**: Goal content must meet validation requirements - pub fn add_goal(env: Env, creator: Address, course_id: String, content: String) -> CourseGoal { - functions::add_goal::add_goal(env, creator, course_id, content) + pub fn add_goal(env: Env, creator: Address, course_id: String, content_hash: String) -> CourseGoal { + functions::add_goal::add_goal(env, creator, course_id, content_hash) } /// Remove a goal from a course. @@ -549,206 +460,7 @@ impl CourseRegistry { functions::remove_goal::remove_goal(env, caller, course_id, goal_id) } - /// Add prerequisites to a course. - /// - /// This function adds prerequisite courses that must be completed - /// before a student can enroll in the target course. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course - /// * `prerequisite_course_ids` - Vector of course IDs that are prerequisites - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If any prerequisite course doesn't exist - /// * If trying to add self as prerequisite - /// - /// # Examples - /// - /// ```rust - /// let mut prerequisites = Vec::new(&env); - /// prerequisites.push_back("basic_rust".try_into().unwrap()); - /// prerequisites.push_back("programming_fundamentals".try_into().unwrap()); - /// - /// contract.add_prerequisite( - /// env.clone(), - /// course_creator_address, - /// "advanced_rust".try_into().unwrap(), - /// prerequisites - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Circular dependencies**: Cannot add self as prerequisite - /// * **Non-existent courses**: All prerequisite courses must exist - /// * **Creator only**: Only course creator can add prerequisites - /// * **Duplicate prerequisites**: Adding same prerequisite multiple times is ignored - pub fn add_prerequisite( - env: Env, - creator: Address, - course_id: String, - prerequisite_course_ids: Vec, - ) { - functions::create_prerequisite::add_prerequisite( - env, - creator, - course_id, - prerequisite_course_ids, - ) - } - - /// Remove a prerequisite from a course. - /// - /// This function removes a specific prerequisite course requirement - /// from the target course. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course - /// * `prerequisite_course_id` - The ID of the prerequisite course to remove - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If prerequisite doesn't exist for the course - /// - /// # Examples - /// - /// ```rust - /// // Remove a prerequisite from a course - /// contract.remove_prerequisite( - /// env.clone(), - /// course_creator_address, - /// "advanced_rust".try_into().unwrap(), - /// "basic_rust".try_into().unwrap() - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Non-existent prerequisite**: Will panic if prerequisite doesn't exist - /// * **Creator only**: Only course creator can remove prerequisites - /// * **No effect**: Removing non-existent prerequisite has no effect - /// * **Student impact**: Consider impact on enrolled students - pub fn remove_prerequisite( - env: Env, - creator: Address, - course_id: String, - prerequisite_course_id: String, - ) { - functions::remove_prerequisite::remove_prerequisite( - env, - creator, - course_id, - prerequisite_course_id, - ) - } - - /// Edit the prerequisites for a course. - /// - /// This function replaces all existing prerequisites with a new set - /// of prerequisite courses. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course - /// * `new_prerequisites` - Vector of new prerequisite course IDs - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If any prerequisite course doesn't exist - /// * If trying to add self as prerequisite - /// - /// # Examples - /// - /// ```rust - /// let mut new_prerequisites = Vec::new(&env); - /// new_prerequisites.push_back("updated_course_1".try_into().unwrap()); - /// new_prerequisites.push_back("updated_course_2".try_into().unwrap()); - /// - /// contract.edit_prerequisite( - /// env.clone(), - /// course_creator_address, - /// "target_course".try_into().unwrap(), - /// new_prerequisites - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Complete replacement**: All old prerequisites are removed - /// * **Empty vector**: Can clear all prerequisites with empty vector - /// * **Circular dependencies**: Cannot add self as prerequisite - /// * **Student impact**: Consider impact on enrolled students - pub fn edit_prerequisite( - env: Env, - creator: Address, - course_id: String, - new_prerequisites: Vec, - ) { - functions::edit_prerequisite::edit_prerequisite(env, creator, course_id, new_prerequisites) - } - - /// Edit course information. - /// - /// This function allows the course creator to update various aspects - /// of the course using the provided parameters. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course to edit - /// * `params` - Parameters containing the fields to update - /// - /// # Returns - /// - /// Returns the updated `Course` object. - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If any field validation fails - /// - /// # Examples - /// - /// ```rust - /// let params = EditCourseParams { - /// title: Some("Updated Course Title".try_into().unwrap()), - /// description: Some("Updated description".try_into().unwrap()), - /// price: Some(7500), - /// level: Some(CourseLevel::Intermediate), - /// ..Default::default() - /// }; - /// - /// let updated_course = contract.edit_course( - /// env.clone(), - /// course_creator_address, - /// "course_123".try_into().unwrap(), - /// params - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Partial updates**: Only provided fields are updated - /// * **Validation**: All fields must pass validation rules - /// * **Creator only**: Only course creator can edit course - /// * **Price limits**: Price must be within allowed bounds + /// Edit an existing course. pub fn edit_course( env: Env, creator: Address, @@ -760,85 +472,12 @@ impl CourseRegistry { /// Archive a course. /// - /// This function marks a course as archived, making it unavailable for new enrollments - /// while preserving existing data and access for current students. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `creator` - The address of the course creator - /// * `course_id` - The unique identifier of the course to archive - /// - /// # Returns - /// - /// Returns the updated `Course` object with archived status. - /// - /// # Panics - /// - /// * If course doesn't exist - /// * If creator is not the course creator - /// * If course is already archived - /// - /// # Examples - /// - /// ```rust - /// // Archive a course - /// let archived_course = contract.archive_course( - /// &env, - /// course_creator_address, - /// "course_123".try_into().unwrap() - /// ); - /// ``` - /// - /// # Edge Cases - /// - /// * **Already archived**: Will panic if course is already archived - /// * **Creator only**: Only course creator can archive course - /// * **Student access**: Current students retain access - /// * **Reversible**: Course can be unarchived if needed - pub fn archive_course(env: &Env, creator: Address, course_id: String) -> Course { - functions::archive_course::archive_course(env, creator, course_id) + /// Returns the archived `Course` with `is_archived` set to `true`. + pub fn archive_course(env: Env, creator: Address, course_id: String) -> Course { + functions::archive_course::archive_course(&env, creator, course_id) } - /// Check if a user is the creator of a specific course. - /// - /// This function verifies whether the specified user is the original creator - /// of the given course. - /// - /// # Arguments - /// - /// * `env` - The Soroban environment - /// * `course_id` - The unique identifier of the course - /// * `user` - The address of the user to check - /// - /// # Returns - /// - /// Returns `true` if the user is the course creator, `false` otherwise. - /// - /// # Panics - /// - /// * If course doesn't exist - /// - /// # Examples - /// - /// ```rust - /// // Check if user is course creator - /// let is_creator = contract.is_course_creator( - /// &env, - /// "course_123".try_into().unwrap(), - /// user_address - /// ); - /// - /// if is_creator { - /// // User can edit this course - /// } - /// ``` - /// - /// # Edge Cases - /// - /// * **Non-existent course**: Will panic if course doesn't exist - /// * **Public access**: Anyone can check creator status - /// * **Creator verification**: Useful for permission checks + /// Check if a user is the course creator. pub fn is_course_creator(env: &Env, course_id: String, user: Address) -> bool { functions::is_course_creator::is_course_creator(env, course_id, user) } @@ -930,6 +569,46 @@ impl CourseRegistry { ) } + /// List modules for a course. + pub fn list_modules(env: Env, course_id: String) -> Vec { + functions::list_modules::list_modules(&env, course_id) + } + + /// Add prerequisites to a course. + pub fn add_prerequisite( + env: Env, + caller: Address, + course_id: String, + prerequisites: Vec, + ) { + functions::create_prerequisite::add_prerequisite(env, caller, course_id, prerequisites) + } + + /// Edit (replace) all prerequisites for a course. + pub fn edit_prerequisite( + env: Env, + caller: Address, + course_id: String, + new_prerequisites: Vec, + ) { + functions::edit_prerequisite::edit_prerequisite(env, caller, course_id, new_prerequisites) + } + + /// Remove a prerequisite from a course. + pub fn remove_prerequisite( + env: Env, + caller: Address, + course_id: String, + prereq_course_id: String, + ) { + functions::remove_prerequisite::remove_prerequisite(env, caller, course_id, prereq_course_id) + } + + /// Get prerequisites for a course. + pub fn get_prerequisites_by_course(env: Env, course_id: String) -> Vec { + functions::get_prerequisites_by_course::get_prerequisites_by_course(&env, course_id) + } + /// Export all course data for backup purposes (admin only) /// /// This function exports all course data including courses, categories, diff --git a/contracts/course/course_registry/src/schema.rs b/contracts/course/course_registry/src/schema.rs index 8888ead..07827b9 100644 --- a/contracts/course/course_registry/src/schema.rs +++ b/contracts/course/course_registry/src/schema.rs @@ -14,22 +14,32 @@ pub const MAX_EMPTY_CHECKS: u32 = 10; pub const DEFAULT_COURSE_RATE_LIMIT_WINDOW: u64 = 3600; // 1 hour in seconds pub const DEFAULT_MAX_COURSE_CREATIONS_PER_WINDOW: u32 = 3; // Max course creations per hour per address +/// Minimal on-chain course module reference. +/// +/// Only stores the module's ID, course association, +/// position, a content hash for integrity verification, and a creation timestamp. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct CourseModule { pub id: String, pub course_id: String, pub position: u32, - pub title: String, + /// SHA-256 hash of the off-chain module content (title, body, etc.) + pub content_hash: String, pub created_at: u64, } +/// Minimal on-chain course goal reference. +/// +/// Only stores the goal's ID, course association, +/// a content hash for integrity verification, creator, and creation timestamp. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct CourseGoal { pub goal_id: String, pub course_id: String, - pub content: String, + /// SHA-256 hash of the off-chain goal content + pub content_hash: String, pub created_by: Address, pub created_at: u64, } @@ -83,17 +93,23 @@ pub enum DataKey { CourseRateLimit(Address), } +/// Lean on-chain course record. +/// +/// Title, description, and thumbnail_url have been moved off-chain. +/// The contract stores only data essential for verifiable credentials, +/// access control, and cryptographic proofs of course content integrity. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Course { pub id: String, - pub title: String, - pub description: String, + /// Off-chain reference ID (UUID mapping to full course record in DB) + pub off_chain_ref_id: String, + /// SHA-256 hash of the off-chain course content (title, description, thumbnail, etc.) + pub content_hash: String, pub creator: Address, pub price: u128, pub category: Option, pub language: Option, - pub thumbnail_url: Option, pub published: bool, pub prerequisites: Vec, pub is_archived: bool, @@ -119,6 +135,10 @@ pub struct Category { // Valid values: "Beginner", "Intermediate", "Advanced" pub type CourseLevel = String; +/// Filters for querying courses. +/// +/// Text search (search_text) removed since title/description are no longer on-chain. +/// Filtering is now limited to on-chain fields: price, category, level, duration. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct CourseFilters { @@ -128,19 +148,22 @@ pub struct CourseFilters { pub level: Option, pub min_duration: Option, pub max_duration: Option, - /// Text search in course title and description - pub search_text: Option, } +/// Parameters for editing an existing course. +/// +/// Title, description, and thumbnail_url fields removed — update these off-chain. +/// The content_hash and off_chain_ref_id can be updated to reflect off-chain changes. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct EditCourseParams { - pub new_title: Option, - pub new_description: Option, + /// New content hash (updated when off-chain content changes) + pub new_content_hash: Option, + /// New off-chain reference ID + pub new_off_chain_ref_id: Option, pub new_price: Option, pub new_category: Option>, pub new_language: Option>, - pub new_thumbnail_url: Option>, pub new_published: Option, pub new_level: Option>, pub new_duration_hours: Option>, @@ -171,4 +194,4 @@ pub struct CourseBackupData { pub backup_timestamp: u64, /// Backup version for compatibility pub backup_version: String, -} \ No newline at end of file +} diff --git a/contracts/course/course_registry/src/test.rs b/contracts/course/course_registry/src/test.rs index c7e46be..8ba46d2 100644 --- a/contracts/course/course_registry/src/test.rs +++ b/contracts/course/course_registry/src/test.rs @@ -6,7 +6,7 @@ use soroban_sdk::{symbol_short, testutils::Address as _, Address, Env, String, V use crate::{ functions::{ - get_prerequisites_by_course::get_prerequisites_by_course_id, + get_prerequisites_by_course::get_prerequisites_by_course, list_categories::list_categories, }, schema::Course, @@ -53,16 +53,15 @@ fn test_remove_module_success() { let creator = Address::generate(&env); let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - let new_module = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "Module Title")); + let new_module = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "module_content_hash_1")); let exists: bool = env.as_contract(&contract_id, || { env.storage() @@ -87,17 +86,16 @@ fn test_remove_multiple_different_modules() { let creator = Address::generate(&env); let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - let module1 = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "Module 1 Title")); - let module2 = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 2 Title")); + let module1 = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "module_hash_1")); + let module2 = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "module_hash_2")); client.remove_module(&module1.id.clone()); client.remove_module(&module2.id.clone()); @@ -124,17 +122,16 @@ fn test_remove_module_storage_isolation() { let creator = Address::generate(&env); let course: Course = client.create_course( &creator, - &String::from_str(&env, "title"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &1000_u128, &Some(String::from_str(&env, "category")), &Some(String::from_str(&env, "language")), - &Some(String::from_str(&env, "thumbnail_url")), &None, &None, ); - let module1 = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "Module 1 Title")); - let module2 = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "Module 2 Title")); + let module1 = client.add_module(&creator, &course.id, &0, &String::from_str(&env, "module_hash_1")); + let module2 = client.add_module(&creator, &course.id, &1, &String::from_str(&env, "module_hash_2")); client.remove_module(&module1.id.clone()); @@ -164,21 +161,20 @@ fn test_get_course_success() { let creator: Address = Address::generate(&env); let course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let retrieved = client.get_course(&course.id); assert_eq!(retrieved.id, course.id); - assert_eq!(retrieved.title, course.title); - assert_eq!(retrieved.description, course.description); + assert_eq!(retrieved.off_chain_ref_id, course.off_chain_ref_id); + assert_eq!(retrieved.content_hash, course.content_hash); assert_eq!(retrieved.creator, course.creator); assert_eq!(retrieved.published, course.published); } @@ -220,14 +216,13 @@ fn test_get_courses_by_instructor_found() { let creator: Address = Address::generate(&env); let course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let results = client.get_courses_by_instructor(&creator); @@ -247,18 +242,17 @@ fn test_get_prerequisites_by_course_id() { let creator: Address = Address::generate(&env); let course = client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "description"), + &String::from_str(&env, "ref-001"), + &String::from_str(&env, "abc123hash"), &crate::schema::DEFAULT_COURSE_PRICE, &None, &None, &None, &None, - &None, ); let prerequisites = env.as_contract(&contract_id, || { - get_prerequisites_by_course_id(&env, course.id.clone()) + get_prerequisites_by_course(&env, course.id.clone()) }); assert!(prerequisites.is_empty()); } @@ -274,36 +268,33 @@ fn test_list_categories_counts() { client.create_course( &creator, - &String::from_str(&env, "A"), - &String::from_str(&env, "d"), + &String::from_str(&env, "ref-A"), + &String::from_str(&env, "hashA"), &10, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); client.create_course( &creator, - &String::from_str(&env, "B"), - &String::from_str(&env, "d"), + &String::from_str(&env, "ref-B"), + &String::from_str(&env, "hashB"), &10, &Some(String::from_str(&env, "Data")), &None, &None, &None, - &None, ); client.create_course( &creator, - &String::from_str(&env, "C"), - &String::from_str(&env, "d"), + &String::from_str(&env, "ref-C"), + &String::from_str(&env, "hashC"), &10, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); // Call the function to list categories @@ -346,14 +337,13 @@ fn test_list_categories_ignores_none() { client.create_course( &creator, - &String::from_str(&env, "B"), - &String::from_str(&env, "d"), + &String::from_str(&env, "ref-B"), + &String::from_str(&env, "hashB"), &10, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); let cats = client.list_categories(); @@ -374,25 +364,23 @@ fn test_list_categories_with_id_gaps() { client.create_course( &creator, - &String::from_str(&env, "Course 1"), - &String::from_str(&env, "Desc"), + &String::from_str(&env, "ref-1"), + &String::from_str(&env, "hash1"), &10, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); client.create_course( &creator, - &String::from_str(&env, "Course 2"), - &String::from_str(&env, "Desc"), + &String::from_str(&env, "ref-2"), + &String::from_str(&env, "hash2"), &10, &Some(String::from_str(&env, "Data")), &None, &None, &None, - &None, ); // Manually delete course 2 to create an ID gap @@ -404,14 +392,13 @@ fn test_list_categories_with_id_gaps() { // Create course 3 (this will still have ID 3) client.create_course( &creator, - &String::from_str(&env, "Course 3"), - &String::from_str(&env, "Desc"), + &String::from_str(&env, "ref-3"), + &String::from_str(&env, "hash3"), &10, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); // Call the function - it should skip missing ID 2 but still count 1 and 3 @@ -444,26 +431,24 @@ fn test_course_backup_and_recovery_system() { // Create test courses let _course1 = client.create_course( &instructor, - &String::from_str(&env, "Rust Programming"), - &String::from_str(&env, "Learn Rust from basics"), + &String::from_str(&env, "ref-rust-prog"), + &String::from_str(&env, "hash_rust_basics"), &1000_u128, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); let _course2 = client.create_course( &instructor, - &String::from_str(&env, "Advanced Rust"), - &String::from_str(&env, "Advanced Rust concepts"), + &String::from_str(&env, "ref-adv-rust"), + &String::from_str(&env, "hash_adv_rust"), &1500_u128, &Some(String::from_str(&env, "Programming")), &None, &None, &None, - &None, ); // Set up admin first (add to admin list) - use contract context diff --git a/contracts/course/course_registry/test_snapshots/test/test_remove_module_storage_isolation.1.json b/contracts/course/course_registry/test_snapshots/test/test_remove_module_storage_isolation.1.json index 40540f2..6ee9373 100644 --- a/contracts/course/course_registry/test_snapshots/test/test_remove_module_storage_isolation.1.json +++ b/contracts/course/course_registry/test_snapshots/test/test_remove_module_storage_isolation.1.json @@ -20,10 +20,10 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, { - "string": "title" + "string": "ref-001" }, { - "string": "description" + "string": "abc123hash" }, { "u128": { @@ -37,9 +37,6 @@ { "string": "language" }, - { - "string": "thumbnail_url" - }, "void", "void" ] @@ -68,7 +65,7 @@ "u32": 0 }, { - "string": "Module 1 Title" + "string": "module_hash_1" } ] } @@ -96,7 +93,7 @@ "u32": 1 }, { - "string": "Module 2 Title" + "string": "module_hash_2" } ] } @@ -350,18 +347,18 @@ }, { "key": { - "symbol": "creator" + "symbol": "content_hash" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + "string": "abc123hash" } }, { "key": { - "symbol": "description" + "symbol": "creator" }, "val": { - "string": "description" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } }, { @@ -400,6 +397,14 @@ }, "val": "void" }, + { + "key": { + "symbol": "off_chain_ref_id" + }, + "val": { + "string": "ref-001" + } + }, { "key": { "symbol": "prerequisites" @@ -426,22 +431,6 @@ "val": { "bool": false } - }, - { - "key": { - "symbol": "thumbnail_url" - }, - "val": { - "string": "thumbnail_url" - } - }, - { - "key": { - "symbol": "title" - }, - "val": { - "string": "title" - } } ] } @@ -489,6 +478,14 @@ "durability": "persistent", "val": { "map": [ + { + "key": { + "symbol": "content_hash" + }, + "val": { + "string": "module_hash_2" + } + }, { "key": { "symbol": "course_id" @@ -520,14 +517,6 @@ "val": { "u32": 1 } - }, - { - "key": { - "symbol": "title" - }, - "val": { - "string": "Module 2 Title" - } } ] } @@ -640,51 +629,6 @@ 4095 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - 4095 - ] - ], [ { "contract_data": { diff --git a/contracts/course/course_registry/test_snapshots/test/test_remove_module_success.1.json b/contracts/course/course_registry/test_snapshots/test/test_remove_module_success.1.json index 26b3273..8922d57 100644 --- a/contracts/course/course_registry/test_snapshots/test/test_remove_module_success.1.json +++ b/contracts/course/course_registry/test_snapshots/test/test_remove_module_success.1.json @@ -20,10 +20,10 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, { - "string": "title" + "string": "ref-001" }, { - "string": "description" + "string": "abc123hash" }, { "u128": { @@ -37,9 +37,6 @@ { "string": "language" }, - { - "string": "thumbnail_url" - }, "void", "void" ] @@ -68,7 +65,7 @@ "u32": 0 }, { - "string": "Module Title" + "string": "module_content_hash_1" } ] } @@ -322,18 +319,18 @@ }, { "key": { - "symbol": "creator" + "symbol": "content_hash" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + "string": "abc123hash" } }, { "key": { - "symbol": "description" + "symbol": "creator" }, "val": { - "string": "description" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } }, { @@ -372,6 +369,14 @@ }, "val": "void" }, + { + "key": { + "symbol": "off_chain_ref_id" + }, + "val": { + "string": "ref-001" + } + }, { "key": { "symbol": "prerequisites" @@ -398,22 +403,6 @@ "val": { "bool": false } - }, - { - "key": { - "symbol": "thumbnail_url" - }, - "val": { - "string": "thumbnail_url" - } - }, - { - "key": { - "symbol": "title" - }, - "val": { - "string": "title" - } } ] } @@ -475,51 +464,6 @@ 4095 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - 4095 - ] - ], [ { "contract_data": { diff --git a/contracts/course/course_registry/test_snapshots/test/test_remove_multiple_different_modules.1.json b/contracts/course/course_registry/test_snapshots/test/test_remove_multiple_different_modules.1.json index 8581bea..4f38b95 100644 --- a/contracts/course/course_registry/test_snapshots/test/test_remove_multiple_different_modules.1.json +++ b/contracts/course/course_registry/test_snapshots/test/test_remove_multiple_different_modules.1.json @@ -20,10 +20,10 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, { - "string": "title" + "string": "ref-001" }, { - "string": "description" + "string": "abc123hash" }, { "u128": { @@ -37,9 +37,6 @@ { "string": "language" }, - { - "string": "thumbnail_url" - }, "void", "void" ] @@ -68,7 +65,7 @@ "u32": 0 }, { - "string": "Module 1 Title" + "string": "module_hash_1" } ] } @@ -96,7 +93,7 @@ "u32": 1 }, { - "string": "Module 2 Title" + "string": "module_hash_2" } ] } @@ -351,18 +348,18 @@ }, { "key": { - "symbol": "creator" + "symbol": "content_hash" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + "string": "abc123hash" } }, { "key": { - "symbol": "description" + "symbol": "creator" }, "val": { - "string": "description" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } }, { @@ -401,6 +398,14 @@ }, "val": "void" }, + { + "key": { + "symbol": "off_chain_ref_id" + }, + "val": { + "string": "ref-001" + } + }, { "key": { "symbol": "prerequisites" @@ -427,22 +432,6 @@ "val": { "bool": false } - }, - { - "key": { - "symbol": "thumbnail_url" - }, - "val": { - "string": "thumbnail_url" - } - }, - { - "key": { - "symbol": "title" - }, - "val": { - "string": "title" - } } ] } @@ -555,51 +544,6 @@ 4095 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "vec": [ - { - "symbol": "title" - }, - { - "string": "title" - } - ] - }, - "durability": "persistent", - "val": { - "bool": true - } - } - }, - "ext": "v0" - }, - 4095 - ] - ], [ { "contract_data": { diff --git a/contracts/user_profile/src/error.rs b/contracts/user_profile/src/error.rs index cbca6ed..3788b29 100644 --- a/contracts/user_profile/src/error.rs +++ b/contracts/user_profile/src/error.rs @@ -10,8 +10,9 @@ pub enum Error { UserProfileNotFound = 1, InvalidInput = 2, UnauthorizedAccess = 3, + OffChainRefIdRequired = 4, } pub fn handle_error(env: &Env, error: Error) -> ! { panic_with_error!(env, error); -} \ No newline at end of file +} diff --git a/contracts/user_profile/src/functions/get_user_profile.rs b/contracts/user_profile/src/functions/get_user_profile.rs index 751bb44..e9e6260 100644 --- a/contracts/user_profile/src/functions/get_user_profile.rs +++ b/contracts/user_profile/src/functions/get_user_profile.rs @@ -9,10 +9,6 @@ use crate::error::{Error, handle_error}; const PROFILE_KEY: Symbol = symbol_short!("profile"); pub fn user_profile_get_user_profile(env: &Env, user_address: Address) -> UserProfile { - // Input validation - // If Address type supports is_empty or similar, add check. Otherwise, skip. - // For demonstration, assume Address cannot be empty. - // Get the user profile from storage with proper error handling match env .storage() @@ -23,21 +19,3 @@ pub fn user_profile_get_user_profile(env: &Env, user_address: Address) -> UserPr None => handle_error(env, Error::UserProfileNotFound), } } - -// Function to get user profile with privacy check -// Returns profile only if it's public or if the requester is the profile owner -pub fn get_user_profile_with_privacy( - env: &Env, - user_address: Address, - requester_address: Address, -) -> UserProfile { - // Reuse the optimized get_user_profile function - let mut profile: UserProfile = user_profile_get_user_profile(env, user_address.clone()); - - // Check privacy settings and apply privacy filters without additional storage reads - if !profile.privacy_public && requester_address != user_address { - profile.email = None; - // Add more privacy filters as needed - } - profile -} diff --git a/contracts/user_profile/src/lib.rs b/contracts/user_profile/src/lib.rs index e6cba71..42330a5 100644 --- a/contracts/user_profile/src/lib.rs +++ b/contracts/user_profile/src/lib.rs @@ -18,8 +18,9 @@ use soroban_sdk::{contract, contractimpl, Address, Env}; /// User Profile Contract /// -/// This contract provides read-only access to user profile information -/// with privacy controls and permission checks. +/// This contract provides read-only access to on-chain user profile +/// information. All PII is stored off-chain; only the blockchain address, +/// an off-chain reference ID, and optional DID hash are stored on-chain. #[contract] pub struct UserProfileContract; @@ -27,8 +28,8 @@ pub struct UserProfileContract; impl UserProfileContract { /// Get a user profile by address. /// - /// This function retrieves a user's profile information using their blockchain address. - /// This is a public function that returns basic profile information. + /// Retrieves the on-chain user profile (address, off_chain_ref_id, + /// did_hash, timestamps) using the user's blockchain address. /// /// # Arguments /// @@ -37,35 +38,33 @@ impl UserProfileContract { /// /// # Returns /// - /// Returns the `UserProfile` containing the user's information. + /// Returns the `UserProfile` containing the user's on-chain data. pub fn get_user_profile(env: Env, user_address: Address) -> UserProfile { functions::get_user_profile::user_profile_get_user_profile(&env, user_address) } - /// Get a user profile with privacy controls. + /// Get a user profile with requester context. /// - /// This function retrieves a user's profile information while respecting - /// privacy settings. Sensitive information like email may be hidden - /// depending on the requester's relationship to the profile owner. + /// This returns the same data + /// as `get_user_profile`. Retained for API backward compatibility. /// /// # Arguments /// /// * `env` - The Soroban environment /// * `user_address` - The blockchain address of the user whose profile to retrieve - /// * `requester_address` - The address of the user requesting the profile + /// * `requester_address` - The address of the user requesting the profile (unused after refactor) /// /// # Returns /// - /// Returns the `UserProfile` with privacy-filtered information. + /// Returns the `UserProfile` with minimal on-chain data. pub fn get_user_profile_with_privacy( env: Env, user_address: Address, - requester_address: Address, + _requester_address: Address, ) -> UserProfile { - functions::get_user_profile::get_user_profile_with_privacy( + functions::get_user_profile::user_profile_get_user_profile( &env, user_address, - requester_address, ) } } diff --git a/contracts/user_profile/src/schema.rs b/contracts/user_profile/src/schema.rs index 21d54ce..b2c8d80 100644 --- a/contracts/user_profile/src/schema.rs +++ b/contracts/user_profile/src/schema.rs @@ -3,27 +3,20 @@ use soroban_sdk::{contracttype, Address, String}; -/// User profile information with privacy controls. +/// on-chain user profile. /// -/// This struct represents a user's profile with optional privacy settings -/// and timestamps for tracking creation and updates. +/// Stores only the blockchain address and an off-chain reference ID +/// that maps to the full user record in the off-chain database. +/// All PII (name, email, country, etc.) is stored off-chain. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct UserProfile { /// User's blockchain address pub address: Address, - /// User's full name - pub name: String, - /// Optional email address (may be hidden for privacy) - pub email: Option, - /// User's country of residence - pub country: String, - /// User's profession or job title - pub profession: String, - /// User's learning goals or objectives - pub goals: String, - /// Whether the profile is publicly viewable - pub privacy_public: bool, + /// Off-chain reference ID (UUID mapping to DB record) + pub off_chain_ref_id: String, + /// Optional DID hash for decentralized identity verification + pub did_hash: Option, /// Timestamp when the profile was created pub created_at: u64, /// Timestamp when the profile was last updated diff --git a/contracts/user_profile/src/test.rs b/contracts/user_profile/src/test.rs index 14004e8..16bd135 100644 --- a/contracts/user_profile/src/test.rs +++ b/contracts/user_profile/src/test.rs @@ -1,20 +1,17 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 SkillCert -use soroban_sdk::{testutils::Address as _, Address, Env, String, Symbol}; +use soroban_sdk::{testutils::Address as _, Address, Env, String, Symbol, symbol_short}; -use crate::{UserProfile, UserProfileContract, UserProfileContractClient}; +use crate::{UserProfileContract, UserProfileContractClient}; +use crate::schema::UserProfile; /// Helper function to create a test user profile fn create_test_profile(env: &Env, address: Address) -> UserProfile { UserProfile { address: address.clone(), - name: String::from_str(env, "John Doe"), - email: Some(String::from_str(env, "john.doe@example.com")), - country: String::from_str(env, "United States"), - profession: String::from_str(env, "Software Engineer"), - goals: String::from_str(env, "Learn blockchain development"), - privacy_public: true, + off_chain_ref_id: String::from_str(env, "usr-abc123-def456"), + did_hash: Some(String::from_str(env, "did:example:abcdef1234567890")), created_at: env.ledger().timestamp(), updated_at: env.ledger().timestamp(), } @@ -22,7 +19,7 @@ fn create_test_profile(env: &Env, address: Address) -> UserProfile { /// Helper function to save a profile to storage fn save_profile_to_storage(env: &Env, profile: &UserProfile) { - let key: Symbol = Symbol::new(env, "profile"); + let key: Symbol = symbol_short!("profile"); env.storage() .instance() .set(&(key, profile.address.clone()), profile); @@ -61,72 +58,49 @@ fn test_get_user_profile_not_found() { } #[test] -fn test_get_user_profile_with_privacy_public_profile() { +fn test_get_user_profile_with_privacy_returns_same_data() { let env: Env = Env::default(); let contract_id: Address = env.register(UserProfileContract, {}); - let client: UserProfileContractClient<'_>= UserProfileContractClient::new(&env, &contract_id); + let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); let user_address: Address = Address::generate(&env); let requester_address: Address = Address::generate(&env); - let mut profile: UserProfile = create_test_profile(&env, user_address.clone()); - profile.privacy_public = true; + let profile: UserProfile = create_test_profile(&env, user_address.clone()); // Save profile to storage env.as_contract(&contract_id, || { save_profile_to_storage(&env, &profile); }); - // Test getting the profile with privacy (should show email for public profile) + // get_user_profile_with_privacy returns the same data + // as get_user_profile since no PII is stored on-chain. let result: UserProfile = client.get_user_profile_with_privacy(&user_address, &requester_address); - assert_eq!(result.email, profile.email); - assert_eq!(result.privacy_public, true); + assert_eq!(result.address, profile.address); + assert_eq!(result.off_chain_ref_id, profile.off_chain_ref_id); + assert_eq!(result.did_hash, profile.did_hash); } #[test] -fn test_get_user_profile_with_privacy_private_profile_owner() { +fn test_get_user_profile_with_privacy_same_user() { let env: Env = Env::default(); let contract_id: Address = env.register(UserProfileContract, {}); let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); let user_address: Address = Address::generate(&env); - let mut profile: UserProfile = create_test_profile(&env, user_address.clone()); - profile.privacy_public = false; + let profile: UserProfile = create_test_profile(&env, user_address.clone()); // Save profile to storage env.as_contract(&contract_id, || { save_profile_to_storage(&env, &profile); }); - // Test getting the profile with privacy (owner should see email) + // Same user requesting their own profile let result: UserProfile = client.get_user_profile_with_privacy(&user_address, &user_address); - assert_eq!(result.email, profile.email); - assert_eq!(result.privacy_public, false); -} - -#[test] -fn test_get_user_profile_with_privacy_private_profile_other_user() { - let env: Env = Env::default(); - let contract_id: Address = env.register(UserProfileContract, {}); - let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); - - let user_address: Address = Address::generate(&env); - let requester_address: Address = Address::generate(&env); - let mut profile: UserProfile = create_test_profile(&env, user_address.clone()); - profile.privacy_public = false; - - // Save profile to storage - env.as_contract(&contract_id, || { - save_profile_to_storage(&env, &profile); - }); - - // Test getting the profile with privacy (other user should not see email) - let result: UserProfile = client.get_user_profile_with_privacy(&user_address, &requester_address); - assert_eq!(result.email, None); // Email should be hidden - assert_eq!(result.privacy_public, false); + assert_eq!(result, profile); } #[test] -fn test_get_user_profile_with_privacy_public_profile_other_user() { +fn test_get_user_profile_with_privacy_different_user() { let env: Env = Env::default(); let contract_id: Address = env.register(UserProfileContract, {}); let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); @@ -140,30 +114,11 @@ fn test_get_user_profile_with_privacy_public_profile_other_user() { save_profile_to_storage(&env, &profile); }); - // Test getting the profile with privacy (other user should see email for public profile) + // Different user requesting the profile — returns same data + // since no PII is on-chain (privacy is handled off-chain) let result: UserProfile = client.get_user_profile_with_privacy(&user_address, &requester_address); - assert_eq!(result.email, profile.email); - assert_eq!(result.privacy_public, true); -} - -#[test] -fn test_get_user_profile_with_privacy_same_user() { - let env: Env = Env::default(); - let contract_id: Address = env.register(UserProfileContract, {}); - let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); - - let user_address: Address = Address::generate(&env); - let profile: UserProfile = create_test_profile(&env, user_address.clone()); - - // Save profile to storage - env.as_contract(&contract_id, || { - save_profile_to_storage(&env, &profile); - }); - - // Test getting the profile with privacy (same user should always see their own data) - let result: UserProfile = client.get_user_profile_with_privacy(&user_address, &user_address); - assert_eq!(result.email, profile.email); - assert_eq!(result, profile); + assert_eq!(result.address, profile.address); + assert_eq!(result.off_chain_ref_id, profile.off_chain_ref_id); } #[test] @@ -187,9 +142,13 @@ fn test_profile_data_integrity() { let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); let user_address: Address = Address::generate(&env); - let mut profile: UserProfile = create_test_profile(&env, user_address.clone()); - profile.privacy_public = false; - profile.email = None; // Test with no email + let profile: UserProfile = UserProfile { + address: user_address.clone(), + off_chain_ref_id: String::from_str(&env, "usr-integrity-test"), + did_hash: None, // Test with no DID hash + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + }; // Save profile to storage env.as_contract(&contract_id, || { @@ -199,12 +158,8 @@ fn test_profile_data_integrity() { // Test that all data is preserved correctly let result: UserProfile = client.get_user_profile(&user_address); assert_eq!(result.address, profile.address); - assert_eq!(result.name, profile.name); - assert_eq!(result.email, profile.email); - assert_eq!(result.country, profile.country); - assert_eq!(result.profession, profile.profession); - assert_eq!(result.goals, profile.goals); - assert_eq!(result.privacy_public, profile.privacy_public); + assert_eq!(result.off_chain_ref_id, profile.off_chain_ref_id); + assert_eq!(result.did_hash, profile.did_hash); assert_eq!(result.created_at, profile.created_at); assert_eq!(result.updated_at, profile.updated_at); } @@ -219,9 +174,13 @@ fn test_multiple_users_profiles() { let user2_address: Address = Address::generate(&env); let profile1: UserProfile = create_test_profile(&env, user1_address.clone()); - let mut profile2: UserProfile = create_test_profile(&env, user2_address.clone()); - profile2.name = String::from_str(&env, "Jane Smith"); - profile2.email = Some(String::from_str(&env, "jane.smith@example.com")); + let profile2: UserProfile = UserProfile { + address: user2_address.clone(), + off_chain_ref_id: String::from_str(&env, "usr-jane-789xyz"), + did_hash: Some(String::from_str(&env, "did:example:jane9876543210")), + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + }; // Save both profiles to storage env.as_contract(&contract_id, || { @@ -237,3 +196,52 @@ fn test_multiple_users_profiles() { assert_eq!(result2, profile2); assert_ne!(result1, result2); } + +#[test] +fn test_profile_with_did_hash() { + let env: Env = Env::default(); + let contract_id: Address = env.register(UserProfileContract, {}); + let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); + + let user_address: Address = Address::generate(&env); + let profile: UserProfile = UserProfile { + address: user_address.clone(), + off_chain_ref_id: String::from_str(&env, "usr-did-test"), + did_hash: Some(String::from_str(&env, "sha256:abcdef0123456789")), + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + }; + + // Save profile to storage + env.as_contract(&contract_id, || { + save_profile_to_storage(&env, &profile); + }); + + let result: UserProfile = client.get_user_profile(&user_address); + assert_eq!(result.did_hash, Some(String::from_str(&env, "sha256:abcdef0123456789"))); +} + +#[test] +fn test_profile_without_did_hash() { + let env: Env = Env::default(); + let contract_id: Address = env.register(UserProfileContract, {}); + let client: UserProfileContractClient<'_> = UserProfileContractClient::new(&env, &contract_id); + + let user_address: Address = Address::generate(&env); + let profile: UserProfile = UserProfile { + address: user_address.clone(), + off_chain_ref_id: String::from_str(&env, "usr-no-did"), + did_hash: None, + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + }; + + // Save profile to storage + env.as_contract(&contract_id, || { + save_profile_to_storage(&env, &profile); + }); + + let result: UserProfile = client.get_user_profile(&user_address); + assert_eq!(result.did_hash, None); + assert_eq!(result.off_chain_ref_id, String::from_str(&env, "usr-no-did")); +}