Skip to content
106 changes: 105 additions & 1 deletion contracts/credential-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Symbol>,
description: Option<Symbol>,
issuer_name: Option<Symbol>,
) {
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<CredentialDisplay> {
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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions contracts/credential-nft/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Symbol>,
/// Human-readable description of the credential.
pub description: Option<Symbol>,
/// Name of the issuer organization.
pub issuer_name: Option<Symbol>,
}

/// 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<CredentialDisplay>,
}
26 changes: 25 additions & 1 deletion contracts/credential-nft/src/verify.rs
Original file line number Diff line number Diff line change
@@ -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<u64> {
Expand Down Expand Up @@ -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<CredentialDisplay> = 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
Expand Down
68 changes: 68 additions & 0 deletions contracts/progress-tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions contracts/progress-tracker/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct Course {
///
/// Empty means the course has no prerequisites and enrolls freely.
pub prerequisites: Vec<Symbol>,
/// 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.
Expand Down Expand Up @@ -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<u32>,
}

/// Complete progress snapshot for a learner in a course, aggregated from
Expand All @@ -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<u32>,
}

/// Aggregate statistics for a learner across every course they enrolled in (#232).
Expand Down