diff --git a/contracts/credential-nft/src/lib.rs b/contracts/credential-nft/src/lib.rs index 66d5657..5d80b62 100644 --- a/contracts/credential-nft/src/lib.rs +++ b/contracts/credential-nft/src/lib.rs @@ -6,8 +6,9 @@ mod verify; mod xcall; use chainlearn_shared::ContractMetadata; -use metadata::{CredentialDataKey, CredentialInfo}; +use metadata::{CredentialDataKey, CredentialDisplay, CredentialInfo, CredentialVerification}; use soroban_sdk::{contract, contracterror, contractimpl, Address, Env, Symbol, Vec}; +use mint::validate_metadata_uri; /// Subset of the progress-tracker interface used to verify course completion /// and the score a credential claims. @@ -131,6 +132,98 @@ impl CredentialNft { mint::mint_credential(&env, &to, &course_id, score, &metadata_uri) } + /// Set display properties for a credential. Admin only (#244). + /// + /// # Arguments + /// * `credential_id` - The credential to update + /// * `image_url` - Optional URL of the credential image + /// * `description` - Optional description + /// * `issuer_name` - Optional issuer name + pub fn set_credential_display( + env: Env, + credential_id: u64, + image_url: Option, + description: Option, + issuer_name: Option, + ) { + let admin: Address = env + .storage() + .persistent() + .get(&CredentialDataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + + // Ensure credential exists + if !env + .storage() + .persistent() + .has(&CredentialDataKey::Credential(credential_id)) + { + panic!("credential not found"); + } + + let display = CredentialDisplay { + image_url, + description, + issuer_name, + }; + + env.storage() + .persistent() + .set(&CredentialDataKey::Display(credential_id), &display); + + env.events().publish( + (Symbol::new(&env, "credential_display_set"),), + (credential_id,), + ); + } + + /// Get display properties for a credential (#244). + /// + /// Returns None if no display properties have been set. + /// + /// # Arguments + /// * `credential_id` - The credential to query + pub fn get_credential_display(env: Env, credential_id: u64) -> Option { + env.storage() + .persistent() + .get(&CredentialDataKey::Display(credential_id)) + } + + /// Update the metadata URI for a credential. Admin only (#243). + /// + /// # Arguments + /// * `credential_id` - The credential to update + /// * `new_metadata_uri` - The new metadata URI + pub fn update_credential_metadata(env: Env, credential_id: u64, new_metadata_uri: Symbol) { + Self::require_not_paused(&env); + let admin: Address = env + .storage() + .persistent() + .get(&CredentialDataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + + let mut info: CredentialInfo = env + .storage() + .persistent() + .get(&CredentialDataKey::Credential(credential_id)) + .expect("credential not found"); + + // Validate the new URI using the same rules as mint. + validate_metadata_uri(&env, &new_metadata_uri); + + info.metadata_uri = new_metadata_uri.clone(); + env.storage() + .persistent() + .set(&CredentialDataKey::Credential(credential_id), &info); + + env.events().publish( + (Symbol::new(&env, "credential_metadata_updated"),), + (credential_id, new_metadata_uri), + ); + } + /// Verify a credential and return its info. /// /// # Arguments @@ -157,6 +250,17 @@ impl CredentialNft { verify::verify_credential(&env, credential_id) } + /// Verify a credential and return its full info along with optional display properties (#244). + /// + /// # Arguments + /// * `credential_id` - The credential to verify + /// + /// # Returns + /// A `CredentialVerification` containing the credential info and optional display properties. + pub fn verify_credential_with_display(env: Env, credential_id: u64) -> CredentialVerification { + verify::verify_credential_with_display(&env, credential_id) + } + /// Get a page of credential IDs for a learner. /// /// Reads are paginated so learners holding many credentials do not produce diff --git a/contracts/credential-nft/src/metadata.rs b/contracts/credential-nft/src/metadata.rs index 6b71d8a..bdfed9c 100644 --- a/contracts/credential-nft/src/metadata.rs +++ b/contracts/credential-nft/src/metadata.rs @@ -39,8 +39,32 @@ pub enum CredentialDataKey { Metadata, /// Stores the reason for credential revocation (#194). RevocationReason(u64), + /// Display properties for a credential (#244). + Display(u64), /// Generated certificate URI for learner and course (#223). CertificateURI(Address, Symbol), /// Emergency pause state (#189). Paused, } + +/// Display properties for a credential NFT (#244). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialDisplay { + /// URL of the credential image (e.g., IPFS hash). + pub image_url: Option, + /// Human-readable description of the credential. + pub description: Option, + /// Name of the issuer organization. + pub issuer_name: Option, +} + +/// Combined verification response for a credential (#244). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialVerification { + /// The core credential info. + pub info: CredentialInfo, + /// Optional display properties. + pub display: Option, +} \ No newline at end of file diff --git a/contracts/credential-nft/src/verify.rs b/contracts/credential-nft/src/verify.rs index 670c951..656ff50 100644 --- a/contracts/credential-nft/src/verify.rs +++ b/contracts/credential-nft/src/verify.rs @@ -1,7 +1,7 @@ use chainlearn_shared::MAX_CREDENTIALS_PAGE_SIZE; use soroban_sdk::{Address, Env, Symbol, Vec}; -use crate::metadata::{CredentialDataKey, CredentialInfo}; +use crate::metadata::{CredentialDataKey, CredentialDisplay, CredentialInfo, CredentialVerification}; /// Read the full list of credential IDs owned by a learner. fn learner_credentials(env: &Env, learner: &Address) -> Vec { @@ -29,6 +29,30 @@ pub fn verify_credential(env: &Env, credential_id: u64) -> CredentialInfo { .expect("credential not found") } +/// Verify a credential and return its full info along with optional display properties (#244). +/// +/// # Arguments +/// * `env` - Soroban environment +/// * `credential_id` - The unique credential identifier +/// +/// # Returns +/// A `CredentialVerification` containing the credential info and optional display properties. +/// +/// # Panics +/// If the credential does not exist. +pub fn verify_credential_with_display(env: &Env, credential_id: u64) -> CredentialVerification { + let info: CredentialInfo = env + .storage() + .persistent() + .get(&CredentialDataKey::Credential(credential_id)) + .expect("credential not found"); + let display: Option = env + .storage() + .persistent() + .get(&CredentialDataKey::Display(credential_id)); + CredentialVerification { info, display } +} + /// Get a page of credential IDs belonging to a learner. /// /// Responses are bounded: a learner with thousands of credentials is read one diff --git a/contracts/progress-tracker/src/lib.rs b/contracts/progress-tracker/src/lib.rs index d53c433..3cbf5b4 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -175,6 +175,8 @@ impl ProgressTracker { // No content hash by default; set later via `set_course_content_hash` (#235). content_hash: Symbol::new(&env, EMPTY_CONTENT_HASH), prerequisites: Vec::new(&env), + // Start at version 1; updated via `update_course_version` (#245). + version: 1, }; env.storage() @@ -272,6 +274,7 @@ impl ProgressTracker { total_quiz_score: 0, overall_progress: 0, eligible_for_credential: false, + completed_version: None, }; env.storage().persistent().set(&key, &progress); @@ -479,6 +482,19 @@ impl ProgressTracker { let was_eligible = progress.eligible_for_credential; + progress.overall_progress = rewards::calculate_progress(&course, &progress); + progress.eligible_for_credential = + rewards::is_eligible_for_credential(&course, &progress); + + // Record the course version at the moment eligibility is reached (#245). + if !was_eligible && progress.eligible_for_credential { + progress.completed_version = Some(course.version); + } + + env.storage().persistent().set( + &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), + &progress, + ); progress.overall_progress = rewards::calculate_progress(course, progress); progress.eligible_for_credential = rewards::is_eligible_for_credential(course, progress); @@ -721,6 +737,22 @@ impl ProgressTracker { let was_eligible = progress.eligible_for_credential; + // Recalculate from the updated in-memory aggregates, so everything is + // known before the single storage write below. + progress.overall_progress = rewards::calculate_progress(&course, &progress); + progress.eligible_for_credential = + rewards::is_eligible_for_credential(&course, &progress); + + // Record the course version at the moment eligibility is reached (#245). + if !was_eligible && progress.eligible_for_credential { + progress.completed_version = Some(course.version); + } + + // Single write with all updated fields + env.storage().persistent().set( + &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), + &progress, + ); progress.overall_progress = rewards::calculate_progress(course, progress); progress.eligible_for_credential = rewards::is_eligible_for_credential(course, progress); @@ -846,6 +878,11 @@ impl ProgressTracker { progress.overall_progress = rewards::calculate_progress(&course, &progress); progress.eligible_for_credential = rewards::is_eligible_for_credential(&course, &progress); + // Record the course version at the moment eligibility is reached (#245). + if !was_eligible && progress.eligible_for_credential { + progress.completed_version = Some(course.version); + } + env.storage().persistent().set( &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, @@ -989,6 +1026,7 @@ impl ProgressTracker { total_quiz_score: progress.total_quiz_score, overall_progress: progress.overall_progress, eligible_for_credential: progress.eligible_for_credential, + completed_version: progress.completed_version, } } @@ -1190,6 +1228,36 @@ impl ProgressTracker { ); } + /// Update the version of a course. Admin only (#245). + /// + /// # Arguments + /// * `course_id` - The course to update + /// * `new_version` - The new version number + pub fn update_course_version(env: Env, course_id: Symbol, new_version: u32) { + let admin: Address = env + .storage() + .persistent() + .get(&ProgressTrackerDataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + + let mut course: Course = env + .storage() + .persistent() + .get(&ProgressTrackerDataKey::Course(course_id.clone())) + .expect("course not found"); + + course.version = new_version; + env.storage() + .persistent() + .set(&ProgressTrackerDataKey::Course(course_id.clone()), &course); + + env.events().publish( + (Symbol::new(&env, "course_version_updated"),), + (&course_id, new_version), + ); + } + /// Returns the content hash for a course (#235). /// /// Returns the `none` sentinel when no hash has been set. diff --git a/contracts/progress-tracker/src/types.rs b/contracts/progress-tracker/src/types.rs index e6141ad..48431ce 100644 --- a/contracts/progress-tracker/src/types.rs +++ b/contracts/progress-tracker/src/types.rs @@ -26,6 +26,11 @@ pub struct Course { /// /// Empty means the course has no prerequisites and enrolls freely. pub prerequisites: Vec, + /// Version of the course content (#245). + /// + /// Incremented when off-chain content changes; learners track which + /// version they completed. + pub version: u32, } /// Represents a quiz submission. @@ -62,6 +67,12 @@ pub struct ProgressInfo { pub overall_progress: u32, /// Whether the learner qualifies for a credential. pub eligible_for_credential: bool, + /// The course version when the learner became eligible for a credential (#245). + /// + /// `None` until eligibility is reached, then set to the course's version + /// at that moment so later version bumps do not erase the learner's + /// record. + pub completed_version: Option, } /// Complete progress snapshot for a learner in a course, aggregated from @@ -88,6 +99,8 @@ pub struct ProgressExport { pub overall_progress: u32, /// Whether the learner qualifies for a credential. pub eligible_for_credential: bool, + /// The course version when the learner became eligible (#245). + pub completed_version: Option, } /// Aggregate statistics for a learner across every course they enrolled in (#232).