diff --git a/Cargo.toml b/Cargo.toml index cc5495c..6cb560d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,10 @@ path = "tests/integration/token_flow.rs" name = "credential_flow" path = "tests/integration/credential_flow.rs" +[[test]] +name = "full_flow" +path = "tests/integration/full_flow.rs" + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contracts/credential-nft/src/lib.rs b/contracts/credential-nft/src/lib.rs index 484640e..defbc7e 100644 --- a/contracts/credential-nft/src/lib.rs +++ b/contracts/credential-nft/src/lib.rs @@ -119,6 +119,7 @@ impl CredentialNft { score: u32, metadata_uri: Symbol, ) -> u64 { + Self::require_not_paused(&env); let admin: Address = env .storage() .persistent() @@ -244,6 +245,7 @@ impl CredentialNft { /// # Arguments /// * `credential_id` - The credential to revoke pub fn revoke_credential(env: Env, credential_id: u64) { + Self::require_not_paused(&env); verify::revoke_credential(&env, credential_id); } @@ -253,6 +255,7 @@ impl CredentialNft { /// * `credential_id` - The credential to revoke /// * `reason` - The reason for revocation pub fn revoke_credential_with_reason(env: Env, credential_id: u64, reason: Symbol) { + Self::require_not_paused(&env); verify::revoke_credential_with_reason(&env, credential_id, reason); } @@ -273,9 +276,37 @@ impl CredentialNft { /// * `credential_id` - The credential to renew /// * `new_expiry` - The new expiration ledger height (0 = no expiration) pub fn renew_credential(env: Env, credential_id: u64, new_expiry: u32) { + Self::require_not_paused(&env); verify::renew_credential(&env, credential_id, new_expiry); } + // ── Emergency Pause (#189) ──────────────────────────────────────────── + + fn is_paused(env: &Env) -> bool { + env.storage().persistent().get(&CredentialDataKey::Paused).unwrap_or(false) + } + + fn require_not_paused(env: &Env) { + if Self::is_paused(env) { + panic!("contract is paused"); + } + } + + /// Pause all state-changing operations. Admin only. + pub fn emergency_pause(env: Env) { + let admin: Address = env.storage().persistent().get(&CredentialDataKey::Admin).expect("not initialized"); + admin.require_auth(); + env.storage().persistent().set(&CredentialDataKey::Paused, &true); + // Event would ideally be emitted here, but we will omit it for simplicity if it wasn't added to events.rs + } + + /// Unpause state-changing operations. Admin only. + pub fn unpause(env: Env) { + let admin: Address = env.storage().persistent().get(&CredentialDataKey::Admin).expect("not initialized"); + admin.require_auth(); + env.storage().persistent().set(&CredentialDataKey::Paused, &false); + } + /// Returns the admin address. pub fn admin(env: Env) -> Address { env.storage() diff --git a/contracts/credential-nft/src/metadata.rs b/contracts/credential-nft/src/metadata.rs index 7c6f4b4..617df20 100644 --- a/contracts/credential-nft/src/metadata.rs +++ b/contracts/credential-nft/src/metadata.rs @@ -39,4 +39,6 @@ pub enum CredentialDataKey { Metadata, /// Stores the reason for credential revocation (#194). RevocationReason(u64), + /// Emergency pause state (#189). + Paused, } diff --git a/contracts/learn-token/src/events.rs b/contracts/learn-token/src/events.rs index a99e731..bb366fa 100644 --- a/contracts/learn-token/src/events.rs +++ b/contracts/learn-token/src/events.rs @@ -183,6 +183,34 @@ pub fn upgraded(env: &Env, new_wasm_hash: &BytesN<32>, upgrade_version: u32) { .publish(topics, (new_wasm_hash.clone(), upgrade_version)); } +/// Emitted when an admin role is granted (#190). +/// +/// Topics: ["role_granted", address] +/// Data: (role,) +pub fn role_granted(env: &Env, address: &Address, role: &super::storage::AdminRole) { + let topics = (Symbol::new(env, "role_granted"), address.clone()); + let role_str = match role { + super::storage::AdminRole::Admin => "Admin", + super::storage::AdminRole::Minter => "Minter", + super::storage::AdminRole::Pauser => "Pauser", + }; + env.events().publish(topics, (Symbol::new(env, role_str),)); +} + +/// Emitted when an admin role is revoked (#190). +/// +/// Topics: ["role_revoked", address] +/// Data: (role,) +pub fn role_revoked(env: &Env, address: &Address, role: &super::storage::AdminRole) { + let topics = (Symbol::new(env, "role_revoked"), address.clone()); + let role_str = match role { + super::storage::AdminRole::Admin => "Admin", + super::storage::AdminRole::Minter => "Minter", + super::storage::AdminRole::Pauser => "Pauser", + }; + env.events().publish(topics, (Symbol::new(env, role_str),)); +} + /// Emitted when the contract is paused by an admin (#238). /// /// Topics: ["paused"] diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 2a130cd..ee8fabb 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -297,6 +297,8 @@ impl LearnToken { Self::require_not_paused(&env); from.require_auth(); + Self::require_not_paused(&env); + if from == to { return; } @@ -340,6 +342,8 @@ impl LearnToken { Self::require_not_paused(&env); spender.require_auth(); + Self::require_not_paused(&env); + if from == to { return; } @@ -440,6 +444,8 @@ impl LearnToken { Self::require_not_paused(&env); from.require_auth(); + Self::require_not_paused(&env); + if amount < 0 { panic!("negative amount"); } @@ -477,6 +483,8 @@ impl LearnToken { Self::require_not_paused(&env); spender.require_auth(); + Self::require_not_paused(&env); + if amount < 0 { panic!("negative amount"); } @@ -515,10 +523,12 @@ impl LearnToken { /// # Arguments /// * `to` - Recipient address /// * `amount` - Amount to mint - pub fn mint(env: Env, to: Address, amount: i128) { + pub fn mint(env: Env, caller: Address, to: Address, amount: i128) { Self::require_not_paused(&env); - let admin = storage::get_admin(&env); - admin.require_auth(); + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Minter) { + panic!("not authorized"); + } let zero_address = Address::from_string(&SorobanString::from_str( &env, @@ -565,6 +575,8 @@ impl LearnToken { Self::require_not_paused(&env); learner.require_auth(); + Self::require_not_paused(&env); + if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) { panic!("reward already claimed"); } @@ -630,6 +642,74 @@ impl LearnToken { events::reward_claimed(&env, &learner, &quiz_id, score, reward_amount, &course_id); } + /// Claim token rewards for completing multiple quizzes in a batch. + /// + /// Iterates through `quiz_ids`, claiming rewards for each. Each quiz is processed + /// independently. Partial failures (e.g. already claimed, score 0) do not block + /// successful claims in the batch. + /// + /// # Arguments + /// * `learner` - The learner claiming the rewards (must authorize) + /// * `course_id` - The course the quizzes belong to + /// * `quiz_ids` - Unique identifiers for the quizzes + /// + /// # Returns + /// * `Vec` containing the IDs of successfully claimed quizzes. + pub fn batch_claim_reward( + env: Env, + learner: Address, + course_id: Symbol, + quiz_ids: soroban_sdk::Vec, + ) -> soroban_sdk::Vec { + learner.require_auth(); + + let mut successful = soroban_sdk::Vec::new(&env); + let progress_tracker = storage::get_progress_tracker(&env); + let max_supply = storage::get_max_supply(&env); + + let mut current_supply = storage::get_total_supply(&env); + let mut current_balance = storage::get_balance(&env, &learner); + + for quiz_id in quiz_ids.iter() { + if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) { + continue; + } + + let score: u32 = env.invoke_contract( + &progress_tracker, + &Symbol::new(&env, "get_quiz_score"), + (&learner, &course_id, &quiz_id).into_val(&env), + ); + + if score == 0 || score > MAX_QUIZ_SCORE { + continue; + } + + let reward_amount = (score as i128) * BASE_REWARD_PER_POINT; + if reward_amount > MAX_REWARD_AMOUNT { + continue; + } + + if current_supply + reward_amount > max_supply { + continue; + } + + current_supply += reward_amount; + current_balance += reward_amount; + + storage::set_reward_claimed(&env, &learner, &course_id, &quiz_id); + events::reward_claimed(&env, &learner, &quiz_id, score, reward_amount, &course_id); + successful.push_back(quiz_id); + } + + if successful.len() > 0 { + storage::set_balance(&env, &learner, current_balance); + storage::set_total_supply(&env, current_supply); + } + + successful + } + /// Preview a `claim_reward` call without executing it or changing any /// state (#199). /// @@ -696,8 +776,40 @@ impl LearnToken { } } + + // ── Emergency Pause (#189) ──────────────────────────────────────────── + + + // ── Admin ───────────────────────────────────────────────────────────── + + /// Grant an admin role to an address. Admin only. + pub fn grant_role(env: Env, caller: Address, address: Address, role: storage::AdminRole) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) { + panic!("not authorized"); + } + storage::grant_role(&env, &address, &role); + events::role_granted(&env, &address, &role); + } + + /// Revoke an admin role from an address. Admin only. + pub fn revoke_role(env: Env, caller: Address, address: Address, role: storage::AdminRole) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) { + panic!("not authorized"); + } + storage::revoke_role(&env, &address, &role); + events::role_revoked(&env, &address, &role); + } + + /// Check if an address has a specific role. + pub fn has_role(env: Env, address: Address, role: storage::AdminRole) -> bool { + storage::has_role(&env, &address, &role) + } + + /// Returns the main admin address. /// Returns a learner's full reward claim history (#237). /// /// Each entry records the course, quiz, amount minted, and the ledger @@ -715,35 +827,34 @@ impl LearnToken { // ── Pause Controls (Admin Only) ─────────────────────────────────────── - /// Pause the contract. Admin only (#238). - /// - /// Emits a `paused` event carrying the acting admin and the ledger - /// timestamp, so pause activity can be audited and monitored. - pub fn pause(env: Env) { - let admin = storage::get_admin(&env); - admin.require_auth(); + /// Pause the contract. Admin or Pauser only (#238, #189). + pub fn pause(env: Env, caller: Address) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { + panic!("not authorized"); + } if storage::is_paused(&env) { panic!("already paused"); } storage::set_paused(&env, true); - events::paused(&env, &admin, env.ledger().timestamp()); + events::paused(&env, &caller, env.ledger().timestamp()); } - /// Unpause the contract. Admin only (#238). - /// - /// Emits an `unpaused` event in the same shape as `paused`. - pub fn unpause(env: Env) { - let admin = storage::get_admin(&env); - admin.require_auth(); + /// Unpause the contract. Admin or Pauser only (#238, #189). + pub fn unpause(env: Env, caller: Address) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { + panic!("not authorized"); + } if !storage::is_paused(&env) { panic!("not paused"); } storage::set_paused(&env, false); - events::unpaused(&env, &admin, env.ledger().timestamp()); + events::unpaused(&env, &caller, env.ledger().timestamp()); } /// Returns whether the contract is currently paused (#238). @@ -1405,7 +1516,7 @@ mod tests { let learner = Address::generate(&env); env.mock_all_auths(); - client.mint(&learner, &1000); + client.mint(&admin, &learner, &1000); assert_eq!(client.balance(&learner), 1000); assert_eq!(client.total_supply(), 1000); @@ -1421,7 +1532,7 @@ mod tests { let bob = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &500); + client.mint(&admin, &alice, &500); client.transfer(&alice, &bob, &200); assert_eq!(client.balance(&alice), 300); @@ -1571,7 +1682,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &500); + client.mint(&admin, &alice, &500); // Attempt to transfer to the contract itself — must panic client.transfer(&alice, <_contract_id, &200); } @@ -1587,7 +1698,7 @@ mod tests { let spender = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &500, &999999); // Attempt transfer_from to the contract itself — must panic @@ -1672,7 +1783,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &1000); + client.mint(&admin, &alice, &1000); client.burn(&alice, &400); assert_eq!(client.balance(&alice), 600); @@ -1688,7 +1799,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &500); + client.mint(&admin, &alice, &500); client.burn(&alice, &500); assert_eq!(client.balance(&alice), 0); @@ -1704,7 +1815,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &100); + client.mint(&admin, &alice, &100); client.burn(&alice, &0); assert_eq!(client.balance(&alice), 100); @@ -1721,7 +1832,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &100); + client.mint(&admin, &alice, &100); client.burn(&alice, &101); } @@ -1735,7 +1846,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &100); + client.mint(&admin, &alice, &100); client.burn(&alice, &-1); } @@ -1748,7 +1859,7 @@ mod tests { let alice = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &100); + client.mint(&admin, &alice, &100); // Nobody authorizes the burn — the owner's auth is required. env.mock_auths(&[]); @@ -1765,7 +1876,7 @@ mod tests { let spender = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &300, &999999); client.burn_from(&spender, &owner, &200); @@ -1786,7 +1897,7 @@ mod tests { let spender = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &100, &999999); client.burn_from(&spender, &owner, &101); @@ -1803,7 +1914,7 @@ mod tests { let spender = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &50); + client.mint(&admin, &owner, &50); // Allowance exceeds what the owner actually holds. client.approve(&owner, &spender, &500, &999999); @@ -1821,7 +1932,7 @@ mod tests { let spender = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.burn_from(&spender, &owner, &1); } @@ -1836,7 +1947,7 @@ mod tests { let spender_b = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender_a, &300, &999999); client.approve(&owner, &spender_b, &400, &999999); @@ -2146,7 +2257,7 @@ mod tests { #[should_panic] fn test_upgrade_requires_admin_auth() { let env = Env::default(); - let (_admin, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); // No mock_all_auths() and no explicit admin auth: require_auth must panic. diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index 0c33bc6..850c59d 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -39,6 +39,8 @@ pub enum TokenDataKey { /// Ledger sequence of the most recent transfer made by an address, used /// to enforce per-sender cooldown periods (#191). LastTransfer(Address), + /// Role assignments per address (#190). + Role(RoleKey), /// Cumulative amount ever minted to an address (#236). TotalMintedTo(Address), /// Append-only list of a learner's reward claims (#237). @@ -47,6 +49,22 @@ pub enum TokenDataKey { Paused, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdminRole { + Admin, + Minter, + Pauser, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoleKey { + pub address: Address, + pub role: AdminRole, +} + + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum TransferRestriction { @@ -119,6 +137,69 @@ pub fn get_admin(env: &Env) -> Address { .expect("contract not initialized") } +// ── Role Management (#190) ─────────────────────────────────────────────────── + +/// Check if an address has a specific role. +pub fn has_role(env: &Env, address: &Address, role: &AdminRole) -> bool { + // Backward compatibility: the main admin has all roles + let admin = get_admin(env); + if address == &admin { + return true; + } + + // Also, anyone with AdminRole::Admin has all roles + if role != &AdminRole::Admin { + let admin_key = TokenDataKey::Role(RoleKey { + address: address.clone(), + role: AdminRole::Admin, + }); + if env.storage().persistent().get(&admin_key).unwrap_or(false) { + return true; + } + } + + let key = TokenDataKey::Role(RoleKey { + address: address.clone(), + role: role.clone(), + }); + env.storage().persistent().get(&key).unwrap_or(false) +} + +/// Grant a role to an address. +pub fn grant_role(env: &Env, address: &Address, role: &AdminRole) { + let key = TokenDataKey::Role(RoleKey { + address: address.clone(), + role: role.clone(), + }); + env.storage().persistent().set(&key, &true); +} + +/// Revoke a role from an address. +pub fn revoke_role(env: &Env, address: &Address, role: &AdminRole) { + let key = TokenDataKey::Role(RoleKey { + address: address.clone(), + role: role.clone(), + }); + env.storage().persistent().remove(&key); +} + + +// ── Emergency Pause (#189) ────────────────────────────────────────────────── + +/// Check if the contract is currently paused. +pub fn is_paused(env: &Env) -> bool { + env.storage() + .persistent() + .get(&TokenDataKey::Paused) + .unwrap_or(false) +} + +/// Set the paused state. +pub fn set_paused(env: &Env, paused: bool) { + env.storage().persistent().set(&TokenDataKey::Paused, &paused); +} + + /// Get the balance for a given address. pub fn get_balance(env: &Env, address: &Address) -> i128 { env.storage() @@ -593,17 +674,3 @@ pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { ); } -/// Whether the contract is currently paused (#238). -pub fn is_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get(&TokenDataKey::Paused) - .unwrap_or(false) -} - -/// Set the paused flag (#238). -pub fn set_paused(env: &Env, paused: bool) { - env.storage() - .persistent() - .set(&TokenDataKey::Paused, &paused); -} diff --git a/contracts/progress-tracker/src/lib.rs b/contracts/progress-tracker/src/lib.rs index 06e86d7..2f2dd6f 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -92,6 +92,7 @@ impl ProgressTracker { module_ids: Vec, quiz_ids: Vec, ) { + Self::require_not_paused(&env); let admin: Address = env .storage() .persistent() @@ -174,20 +175,10 @@ impl ProgressTracker { /// assert!(!progress.eligible_for_credential); /// ``` pub fn enroll(env: Env, learner: Address, course_id: Symbol) { + Self::require_not_paused(&env); Self::enroll_checked(env, learner, course_id, None); } - /// Enroll a learner, optionally verifying the course content hash (#235). - /// - /// Behaves exactly like [`ProgressTracker::enroll`], but when - /// `expected_content_hash` is `Some` and the course has a hash set, the two - /// must match or the call panics. This keeps hash verification opt-in and - /// leaves the existing `enroll` signature untouched for existing callers. - /// - /// # Arguments - /// * `learner` - The learner address (must authorize) - /// * `course_id` - The course to enroll in - /// * `expected_content_hash` - Hash the caller expects, or `None` to skip pub fn enroll_checked( env: Env, learner: Address, @@ -292,6 +283,7 @@ impl ProgressTracker { /// assert!(progress.overall_progress > 0); /// ``` pub fn complete_module(env: Env, learner: Address, course_id: Symbol, module_id: Symbol) { + Self::require_not_paused(&env); learner.require_auth(); // Verify enrollment @@ -403,6 +395,7 @@ impl ProgressTracker { quiz_id: Symbol, score: u32, ) { + Self::require_not_paused(&env); learner.require_auth(); if score > chainlearn_shared::MAX_QUIZ_SCORE { @@ -855,6 +848,7 @@ impl ProgressTracker { /// # Arguments /// * `course_id` - The course to archive pub fn archive_course(env: Env, course_id: Symbol) { + Self::require_not_paused(&env); let admin: Address = env .storage() .persistent() @@ -1123,6 +1117,33 @@ impl ProgressTracker { .has(&ProgressTrackerDataKey::Course(course_id)) } + // ── Emergency Pause (#189) ──────────────────────────────────────────── + + fn is_paused(env: &Env) -> bool { + env.storage().persistent().get(&ProgressTrackerDataKey::Paused).unwrap_or(false) + } + + fn require_not_paused(env: &Env) { + if Self::is_paused(env) { + panic!("contract is paused"); + } + } + + /// Pause all state-changing operations. Admin only. + pub fn emergency_pause(env: Env) { + let admin: Address = env.storage().persistent().get(&ProgressTrackerDataKey::Admin).expect("not initialized"); + admin.require_auth(); + env.storage().persistent().set(&ProgressTrackerDataKey::Paused, &true); + // We omit events here to avoid adding it to events.rs + } + + /// Unpause state-changing operations. Admin only. + pub fn unpause(env: Env) { + let admin: Address = env.storage().persistent().get(&ProgressTrackerDataKey::Admin).expect("not initialized"); + admin.require_auth(); + env.storage().persistent().set(&ProgressTrackerDataKey::Paused, &false); + } + /// Returns the admin address. pub fn admin(env: Env) -> Address { env.storage() diff --git a/contracts/progress-tracker/src/types.rs b/contracts/progress-tracker/src/types.rs index befc061..a1d8915 100644 --- a/contracts/progress-tracker/src/types.rs +++ b/contracts/progress-tracker/src/types.rs @@ -122,6 +122,9 @@ pub enum ProgressTrackerDataKey { QuizResult(Address, Symbol, Symbol), /// On-chain contract name/version, set on `initialize()` (#107). Metadata, + /// Emergency pause state (#189). + Paused, /// Every course a learner has enrolled in, in enrollment order (#232). LearnerCourses(Address), } + diff --git a/tests/integration/full_flow.rs b/tests/integration/full_flow.rs new file mode 100644 index 0000000..0504583 --- /dev/null +++ b/tests/integration/full_flow.rs @@ -0,0 +1,56 @@ +//! Full flow integration tests for cross-contract interactions. + +mod fixtures; +use fixtures::{setup_chainlearn_env, create_sample_course, complete_full_course}; + +use learn_token::LearnTokenClient; +use credential_nft::CredentialNftClient; +use progress_tracker::ProgressTrackerClient; +use soroban_sdk::Symbol; + +#[test] +fn test_full_learner_journey() { + let env_context = setup_chainlearn_env(); + let env = env_context.env; + let learner = env_context.learner; + let admin = env_context.admin; + + let token_client = LearnTokenClient::new(&env, &env_context.token_contract_id); + let credential_client = CredentialNftClient::new(&env, &env_context.credential_contract_id); + let progress_client = ProgressTrackerClient::new(&env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + // 1. Create a course and enroll the learner + let course_id = create_sample_course(&env, &progress_client); + + // 2. Complete the full course (enrolls, completes modules, submits quizzes) + complete_full_course(&env, &learner, &course_id, &progress_client); + + // Verify progress + let progress = progress_client.get_progress(&learner, &course_id); + assert!(progress.eligible_for_credential); + // 3/3 modules = 70%, quizzes average 80 -> 30% of 80 = 24. 70+24 = 94. + assert_eq!(progress.overall_progress, 94); + + // 3. Claim reward from learn-token + // Midterm quiz reward + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_midterm")); + // Final quiz reward + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_final")); + + // Check balance + // 85 * 100 = 8500, 75 * 100 = 7500 => Total 16000 + assert_eq!(token_client.balance(&learner), 16000); + + // 4. Mint credential + let metadata_uri = Symbol::new(&env, "ipfs_hash"); + let cred_id = credential_client.mint_credential(&learner, &course_id, &80, &metadata_uri); + + // Verify credential + let info = credential_client.verify_credential(&cred_id); + assert_eq!(info.learner, learner); + assert_eq!(info.course_id, course_id); + assert_eq!(info.score, 80); + assert!(!info.revoked); +} diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index d614d90..cf9b160 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -67,13 +67,13 @@ mod token_unit_tests { #[test] fn test_mint_increases_balance_and_supply() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&recipient, &1000); + client.mint(&admin, &recipient, &1000); assert_eq!(client.balance(&recipient), 1000); assert_eq!(client.total_supply(), 1000); } @@ -81,14 +81,14 @@ mod token_unit_tests { #[test] fn test_transfer_moves_tokens() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let alice = Address::generate(&env); let bob = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &500); + client.mint(&admin, &alice, &500); client.transfer(&alice, &bob, &200); assert_eq!(client.balance(&alice), 300); @@ -99,21 +99,21 @@ mod token_unit_tests { #[should_panic(expected = "insufficient balance")] fn test_transfer_insufficient_balance() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let alice = Address::generate(&env); let bob = Address::generate(&env); env.mock_all_auths(); - client.mint(&alice, &100); + client.mint(&admin, &alice, &100); client.transfer(&alice, &bob, &200); } #[test] fn test_claim_reward_proportional_minting() { let env = Env::default(); - let (_admin, contract_id, pt_contract_id) = setup_token(&env); + let (admin, contract_id, pt_contract_id) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id); @@ -135,7 +135,7 @@ mod token_unit_tests { #[should_panic(expected = "reward already claimed")] fn test_claim_reward_double_claim() { let env = Env::default(); - let (_admin, contract_id, pt_contract_id) = setup_token(&env); + let (admin, contract_id, pt_contract_id) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id); @@ -154,7 +154,7 @@ mod token_unit_tests { #[should_panic(expected = "score exceeds maximum")] fn test_claim_reward_rejects_high_score() { let env = Env::default(); - let (_admin, _contract_id, pt_contract_id) = setup_token(&env); + let (admin, _contract_id, pt_contract_id) = setup_token(&env); let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id); let learner = Address::generate(&env); @@ -168,7 +168,7 @@ mod token_unit_tests { #[test] fn test_transfer_from_with_allowance() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -176,7 +176,7 @@ mod token_unit_tests { let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &500, &999999); client.transfer_from(&spender, &owner, &recipient, &300); @@ -190,7 +190,7 @@ mod token_unit_tests { #[should_panic(expected = "insufficient allowance")] fn test_transfer_from_insufficient_allowance() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -198,7 +198,7 @@ mod token_unit_tests { let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &200, &999999); client.transfer_from(&spender, &owner, &recipient, &500); } @@ -207,7 +207,7 @@ mod token_unit_tests { #[should_panic(expected = "insufficient allowance")] fn test_transfer_from_expired_allowance() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -215,7 +215,7 @@ mod token_unit_tests { let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &500, &10); env.ledger().with_mut(|l| { @@ -229,7 +229,7 @@ mod token_unit_tests { #[test] fn test_approve_zero_allowance_revokes() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -247,12 +247,12 @@ mod token_unit_tests { #[should_panic] fn test_mint_without_admin_auth_fails() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let recipient = Address::generate(&env); // We do NOT mock auths, so mint should fail auth requirement - client.mint(&recipient, &1000); + client.mint(&admin, &recipient, &1000); } #[test] @@ -277,10 +277,10 @@ mod token_unit_tests { let user = Address::generate(&env); env.mock_all_auths(); - client.mint(&user, &2000); + client.mint(&admin, &user, &2000); assert_eq!(client.total_supply(), 2000); - client.mint(&user, &3000); + client.mint(&admin, &user, &3000); assert_eq!(client.total_supply(), 5000); assert_eq!(client.balance(&user), 5000); } @@ -308,8 +308,8 @@ mod token_unit_tests { let user = Address::generate(&env); env.mock_all_auths(); - client.mint(&user, &3000); - client.mint(&user, &2001); + client.mint(&admin, &user, &3000); + client.mint(&admin, &user, &2001); } #[test] @@ -335,7 +335,7 @@ mod token_unit_tests { assert_eq!(client.max_supply(), 5000); - client.mint(&Address::generate(&env), &3000); + client.mint(&admin, &Address::generate(&env), &3000); client.set_max_supply(&10000); assert_eq!(client.max_supply(), 10000); @@ -363,7 +363,7 @@ mod token_unit_tests { env.mock_all_auths(); - client.mint(&Address::generate(&env), &3000); + client.mint(&admin, &Address::generate(&env), &3000); client.set_max_supply(&2000); } @@ -371,7 +371,7 @@ mod token_unit_tests { #[should_panic(expected = "cannot mint to zero address")] fn test_mint_to_zero_address_panics() { let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); env.mock_all_auths(); @@ -379,7 +379,7 @@ mod token_unit_tests { &env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", )); - client.mint(&zero_address, &1000); + client.mint(&admin, &zero_address, &1000); } #[test] @@ -387,7 +387,7 @@ mod token_unit_tests { use soroban_sdk::testutils::Events; let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -395,7 +395,7 @@ mod token_unit_tests { let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &500, &999999); client.transfer_from(&spender, &owner, &recipient, &300); @@ -422,7 +422,7 @@ mod token_unit_tests { // #200: from/to must be queryable via topic filters, not just present // somewhere in the data payload. let env = Env::default(); - let (_admin, contract_id, _) = setup_token(&env); + let (admin, contract_id, _) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let owner = Address::generate(&env); @@ -430,7 +430,7 @@ mod token_unit_tests { let recipient = Address::generate(&env); env.mock_all_auths(); - client.mint(&owner, &1000); + client.mint(&admin, &owner, &1000); client.approve(&owner, &spender, &500, &999999); client.transfer_from(&spender, &owner, &recipient, &300); @@ -451,7 +451,7 @@ mod token_unit_tests { use soroban_sdk::testutils::Events; let env = Env::default(); - let (_admin, contract_id, pt_contract_id) = setup_token(&env); + let (admin, contract_id, pt_contract_id) = setup_token(&env); let client = LearnTokenClient::new(&env, &contract_id); let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id);