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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/target
test_snapshots/
.mimo/
*.mimo
mimo/
86 changes: 86 additions & 0 deletions contracts/credential-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,67 @@ impl CredentialNft {
from.require_auth();
panic!("credentials are soulbound and non-transferable");
}

/// Generate a course completion certificate URI for a learner and course (#223).
///
/// The certificate URI is stored on-chain in credential metadata and is unique per learner and course.
///
/// # Arguments
/// * `learner` - The learner address (must authorize)
/// * `course_id` - The course identifier
///
/// # Returns
/// The generated certificate URI symbol.
pub fn generate_certificate(env: Env, learner: Address, course_id: Symbol) -> Symbol {
learner.require_auth();

let cert_key = CredentialDataKey::CertificateURI(learner.clone(), course_id.clone());
if let Some(uri) = env.storage().persistent().get::<_, Symbol>(&cert_key) {
return uri;
}

// Check if credential exists for learner & course, or verify eligibility
let dup_key = CredentialDataKey::CourseCredential(learner.clone(), course_id.clone());
if !env.storage().persistent().has(&dup_key) {
let progress_tracker: Address = env
.storage()
.persistent()
.get(&CredentialDataKey::ProgressTracker)
.expect("not initialized");
let tracker = ProgressTrackerClient::new(&env, &progress_tracker);
if !tracker.course_exists(&course_id) {
panic!("course does not exist");
}
if !tracker.is_eligible_for_credential(&learner, &course_id) {
panic!("learner has not completed the course requirements");
}
}

let cert_uri = Symbol::new(&env, "cert_uri");
env.storage().persistent().set(&cert_key, &cert_uri);

// If credential already minted, update metadata_uri in CredentialInfo
if let Some(cred_id) = env.storage().persistent().get::<_, u64>(&dup_key) {
let cred_key = CredentialDataKey::Credential(cred_id);
if let Some(mut info) = env.storage().persistent().get::<_, CredentialInfo>(&cred_key) {
info.metadata_uri = cert_uri.clone();
env.storage().persistent().set(&cred_key, &info);
}
}

env.events().publish(
(Symbol::new(&env, "certificate_generated"), learner.clone(), course_id.clone()),
(cert_uri.clone(),),
);

cert_uri
}

/// Query the generated certificate URI for a learner and course (#223).
pub fn get_certificate_uri(env: Env, learner: Address, course_id: Symbol) -> Option<Symbol> {
let cert_key = CredentialDataKey::CertificateURI(learner, course_id);
env.storage().persistent().get(&cert_key)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -1112,4 +1173,29 @@ mod tests {
let after = client.get_credentials_by_course(&course);
assert_eq!(after.len(), 0);
}

// ── Issue #223: certificate generation tests ──────────────────────────────

#[test]
fn test_generate_certificate() {
let env = Env::default();
let (_admin, contract_id, tracker_id) = setup_contract(&env);
let client = CredentialNftClient::new(&env, &contract_id);

let learner = Address::generate(&env);
env.mock_all_auths();
let course = Symbol::new(&env, "rust_101");

enrolled_and_completed(&env, &tracker_id, &learner, &course);

assert_eq!(client.get_certificate_uri(&learner, &course), None);

let cert_uri = client.generate_certificate(&learner, &course);
assert_eq!(client.get_certificate_uri(&learner, &course), Some(cert_uri.clone()));

// Mint credential and check that metadata_uri gets updated with generated certificate URI
let cred_id = client.mint_credential(&learner, &course, &85, &cert_uri);
let info = client.verify_credential(&cred_id);
assert_eq!(info.metadata_uri, cert_uri);
}
}
2 changes: 2 additions & 0 deletions contracts/credential-nft/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub enum CredentialDataKey {
Metadata,
/// Stores the reason for credential revocation (#194).
RevocationReason(u64),
/// Generated certificate URI for learner and course (#223).
CertificateURI(Address, Symbol),
/// Emergency pause state (#189).
Paused,
}
70 changes: 70 additions & 0 deletions contracts/learn-token/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,73 @@ pub fn unpaused(env: &Env, admin: &Address, timestamp: u64) {
let topics = (Symbol::new(env, "unpaused"),);
env.events().publish(topics, (admin, timestamp));
}

/// Emitted when a vesting schedule is created (#225).
///
/// Topics: ["vesting_created", beneficiary]
/// Data: (total_amount, cliff_timestamp, duration_seconds)
pub fn vesting_created(
env: &Env,
beneficiary: &Address,
total_amount: i128,
cliff_timestamp: u64,
duration_seconds: u64,
) {
let topics = (Symbol::new(env, "vesting_created"), beneficiary.clone());
env.events().publish(
topics,
(total_amount, cliff_timestamp, duration_seconds),
);
}

/// Emitted when vested tokens are claimed (#225).
///
/// Topics: ["vesting_claimed", beneficiary]
/// Data: (claimed_amount, total_claimed)
pub fn vesting_claimed(
env: &Env,
beneficiary: &Address,
claimed_amount: i128,
total_claimed: i128,
) {
let topics = (Symbol::new(env, "vesting_claimed"), beneficiary.clone());
env.events().publish(topics, (claimed_amount, total_claimed));
}

/// Emitted when a governance proposal is created (#226).
///
/// Topics: ["proposal_created"]
/// Data: (proposal_id, start_time, end_time)
pub fn proposal_created(env: &Env, proposal_id: u64, start_time: u64, end_time: u64) {
let topics = (Symbol::new(env, "proposal_created"),);
env.events().publish(topics, (proposal_id, start_time, end_time));
}

/// Emitted when a vote is cast on a proposal (#226).
///
/// Topics: ["vote_cast", voter]
/// Data: (proposal_id, choice, voting_power)
pub fn vote_cast(
env: &Env,
proposal_id: u64,
voter: &Address,
choice: u32,
voting_power: i128,
) {
let topics = (Symbol::new(env, "vote_cast"), voter.clone());
env.events().publish(topics, (proposal_id, choice, voting_power));
}

/// Emitted when a governance proposal is executed (#226).
///
/// Topics: ["proposal_executed"]
/// Data: (proposal_id, winning_choice, winning_votes)
pub fn proposal_executed(
env: &Env,
proposal_id: u64,
winning_choice: u32,
winning_votes: i128,
) {
let topics = (Symbol::new(env, "proposal_executed"),);
env.events().publish(topics, (proposal_id, winning_choice, winning_votes));
}
Loading