From 4543c99afd4312a31bd4d321a8a89c9de5a7ef5f Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:56:50 +0100 Subject: [PATCH 1/4] feat(progress-tracker): add course content hash verification Add a content_hash field to Course so clients can verify that off-chain course content matches what the course was published with. - Course gains `content_hash: Symbol`, defaulting to the `none` sentinel meaning "unset", which keeps verification optional. - `set_course_content_hash` (admin only) sets or updates the hash and emits a `content_hash_set` event. Passing `none` unsets it again. - `get_course_content_hash` exposes it for querying. - `enroll_checked` enrolls with optional verification. `enroll` keeps its existing signature and never verifies, so the ~40 existing call sites and the cross-contract calls from credential-nft and learn-token are unaffected. Verification only runs when the course has a hash set AND the caller supplies one, so it is opt-in on both sides. Also fixes a pre-existing build break: tests/unit/progress_tests.rs constructed Course without the `archived` field added in #210, so the progress_tests target failed to compile on main. Closes #235 --- contracts/learn-token/src/storage.rs | 6 +- contracts/progress-tracker/src/lib.rs | 238 +++++++++++++++++++++- contracts/progress-tracker/src/rewards.rs | 5 +- contracts/progress-tracker/src/types.rs | 5 + tests/integration/fixtures.rs | 2 +- tests/integration/token_flow.rs | 40 +++- tests/unit/progress_tests.rs | 34 +++- 7 files changed, 304 insertions(+), 26 deletions(-) diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index fdee519..3e9df46 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -202,7 +202,11 @@ pub fn check_allowance_expired(env: &Env, owner: &Address, spender: &Address) -> } /// Read-only version of check_allowance_expired that does not perform storage side-effects. -pub fn check_allowance_expired_readonly(env: &Env, owner: &Address, spender: &Address) -> (bool, bool, u32) { +pub fn check_allowance_expired_readonly( + env: &Env, + owner: &Address, + spender: &Address, +) -> (bool, bool, u32) { let key = AllowanceKey { owner: owner.clone(), spender: spender.clone(), diff --git a/contracts/progress-tracker/src/lib.rs b/contracts/progress-tracker/src/lib.rs index 3052325..a022b05 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -7,6 +7,9 @@ use chainlearn_shared::ContractMetadata; use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Env, Symbol, Vec}; pub use types::{Course, ProgressExport, ProgressInfo, ProgressTrackerDataKey, QuizResult}; +/// Sentinel meaning "no content hash set"; enrollment skips verification (#235). +const EMPTY_CONTENT_HASH: &str = "none"; + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] @@ -137,6 +140,8 @@ impl ProgressTracker { module_ids: module_ids.clone(), quiz_ids: quiz_ids.clone(), archived: false, + // No content hash by default; set later via `set_course_content_hash` (#235). + content_hash: Symbol::new(&env, EMPTY_CONTENT_HASH), }; env.storage() @@ -166,6 +171,26 @@ impl ProgressTracker { /// assert!(!progress.eligible_for_credential); /// ``` pub fn enroll(env: Env, learner: Address, course_id: Symbol) { + 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, + course_id: Symbol, + expected_content_hash: Option, + ) { learner.require_auth(); // Verify course exists @@ -185,6 +210,15 @@ impl ProgressTracker { panic!("course has no modules"); } + // Content hash verification is optional (#235): it only runs when the + // course has a hash set and the caller supplied one to check against. + if let Some(expected) = expected_content_hash { + let unset = Symbol::new(&env, EMPTY_CONTENT_HASH); + if course.content_hash != unset && course.content_hash != expected { + panic!("course content hash mismatch"); + } + } + // Check not already enrolled let key = ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()); if env.storage().persistent().has(&key) { @@ -288,8 +322,7 @@ 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); + progress.eligible_for_credential = rewards::is_eligible_for_credential(&course, &progress); env.storage().persistent().set( &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), @@ -383,7 +416,7 @@ impl ProgressTracker { }; env.storage().persistent().set(&quiz_key, &result); - + progress.quizzes_submitted += 1; progress.total_quiz_score += score as u64; @@ -392,8 +425,7 @@ impl ProgressTracker { // 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); + progress.eligible_for_credential = rewards::is_eligible_for_credential(&course, &progress); // Single write with all updated fields env.storage().persistent().set( @@ -666,12 +698,59 @@ impl ProgressTracker { .persistent() .set(&ProgressTrackerDataKey::Course(course_id.clone()), &course); + env.events() + .publish((Symbol::new(&env, "course_archived"),), (&course_id,)); + } + + /// Set or update the content hash for a course. Admin only (#235). + /// + /// The hash lets clients verify that off-chain course content matches what + /// the course was published with. Setting it to `none` disables + /// verification again. + /// + /// # Arguments + /// * `course_id` - The course to update + /// * `content_hash` - Hash of the course content, or `none` to unset + pub fn set_course_content_hash(env: Env, course_id: Symbol, content_hash: Symbol) { + 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.content_hash = content_hash.clone(); + env.storage() + .persistent() + .set(&ProgressTrackerDataKey::Course(course_id.clone()), &course); + env.events().publish( - (Symbol::new(&env, "course_archived"),), - (&course_id,), + (Symbol::new(&env, "content_hash_set"),), + (&course_id, &content_hash), ); } + /// Returns the content hash for a course (#235). + /// + /// Returns the `none` sentinel when no hash has been set. + /// + /// # Arguments + /// * `course_id` - The course identifier + pub fn get_course_content_hash(env: Env, course_id: Symbol) -> Symbol { + let course: Course = env + .storage() + .persistent() + .get(&ProgressTrackerDataKey::Course(course_id)) + .expect("course not found"); + course.content_hash + } + /// Check whether a course has been registered via `create_course` (#108). /// /// A cheap existence check -- unlike `get_course`, it never deserializes @@ -783,6 +862,151 @@ mod tests { assert!(client.course_exists(&course_id)); } + // ── Issue #235: course content hash verification ───────────────────── + + #[test] + fn test_course_content_hash_defaults_to_unset() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + + assert_eq!( + client.get_course_content_hash(&course_id), + Symbol::new(&env, EMPTY_CONTENT_HASH) + ); + } + + #[test] + fn test_set_and_query_course_content_hash() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + let hash = Symbol::new(&env, "abc123"); + + client.set_course_content_hash(&course_id, &hash); + + assert_eq!(client.get_course_content_hash(&course_id), hash); + assert_eq!(client.get_course(&course_id).content_hash, hash); + } + + #[test] + fn test_course_content_hash_can_be_updated() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + + client.set_course_content_hash(&course_id, &Symbol::new(&env, "v1")); + client.set_course_content_hash(&course_id, &Symbol::new(&env, "v2")); + + assert_eq!( + client.get_course_content_hash(&course_id), + Symbol::new(&env, "v2") + ); + } + + #[test] + fn test_enroll_without_hash_is_unaffected_by_verification() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + client.set_course_content_hash(&course_id, &Symbol::new(&env, "abc123")); + + // Plain `enroll` never verifies, so a set hash does not block it. + let learner = Address::generate(&env); + client.enroll(&learner, &course_id); + + assert_eq!( + client.get_progress(&learner, &course_id).overall_progress, + 0 + ); + } + + #[test] + fn test_enroll_checked_accepts_matching_hash() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + let hash = Symbol::new(&env, "abc123"); + client.set_course_content_hash(&course_id, &hash); + + let learner = Address::generate(&env); + client.enroll_checked(&learner, &course_id, &Some(hash)); + + assert_eq!( + client.get_progress(&learner, &course_id).overall_progress, + 0 + ); + } + + #[test] + #[should_panic(expected = "course content hash mismatch")] + fn test_enroll_checked_rejects_mismatched_hash() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + client.set_course_content_hash(&course_id, &Symbol::new(&env, "abc123")); + + let learner = Address::generate(&env); + client.enroll_checked(&learner, &course_id, &Some(Symbol::new(&env, "wrong"))); + } + + #[test] + fn test_enroll_checked_skips_verification_when_hash_unset() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + + // Course has no hash set, so verification is skipped even though the + // caller supplied one -- verification is optional. + let learner = Address::generate(&env); + client.enroll_checked(&learner, &course_id, &Some(Symbol::new(&env, "anything"))); + + assert_eq!( + client.get_progress(&learner, &course_id).overall_progress, + 0 + ); + } + + #[test] + fn test_enroll_checked_with_none_skips_verification() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + env.mock_all_auths(); + let course_id = create_test_course(&env, &client); + client.set_course_content_hash(&course_id, &Symbol::new(&env, "abc123")); + + let learner = Address::generate(&env); + client.enroll_checked(&learner, &course_id, &None); + + assert_eq!( + client.get_progress(&learner, &course_id).overall_progress, + 0 + ); + } + // ── Issue #34: verified course score ───────────────────────────────── #[test] diff --git a/contracts/progress-tracker/src/rewards.rs b/contracts/progress-tracker/src/rewards.rs index deabf3a..2d6c228 100644 --- a/contracts/progress-tracker/src/rewards.rs +++ b/contracts/progress-tracker/src/rewards.rs @@ -50,10 +50,7 @@ pub fn average_quiz_score(progress: &ProgressInfo) -> u32 { } /// Determine if a learner is eligible for a credential. -pub fn is_eligible_for_credential( - course: &Course, - progress: &ProgressInfo, -) -> bool { +pub fn is_eligible_for_credential(course: &Course, progress: &ProgressInfo) -> bool { // Check all modules completed let completed = count_completed_modules(progress); if completed < course.total_modules { diff --git a/contracts/progress-tracker/src/types.rs b/contracts/progress-tracker/src/types.rs index f919f7a..861f67c 100644 --- a/contracts/progress-tracker/src/types.rs +++ b/contracts/progress-tracker/src/types.rs @@ -16,6 +16,11 @@ pub struct Course { pub quiz_ids: Vec, /// Whether the course is archived and cannot accept new enrollments (#210). pub archived: bool, + /// Hash of the course's off-chain content, used to verify integrity (#235). + /// + /// Verification is optional: an empty symbol means no hash is set and + /// enrollment skips the check. + pub content_hash: Symbol, } /// Represents a quiz submission. diff --git a/tests/integration/fixtures.rs b/tests/integration/fixtures.rs index 97fbeba..976879c 100644 --- a/tests/integration/fixtures.rs +++ b/tests/integration/fixtures.rs @@ -5,8 +5,8 @@ #![allow(dead_code)] -use learn_token::{LearnToken, LearnTokenClient}; use credential_nft::{CredentialNft, CredentialNftClient}; +use learn_token::{LearnToken, LearnTokenClient}; use progress_tracker::{ProgressTracker, ProgressTrackerClient}; use soroban_sdk::{testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec}; diff --git a/tests/integration/token_flow.rs b/tests/integration/token_flow.rs index 21ec8a3..d19c31c 100644 --- a/tests/integration/token_flow.rs +++ b/tests/integration/token_flow.rs @@ -25,7 +25,13 @@ fn setup_course_and_enroll( for q in quiz_ids { quiz_ids_vec.push_back(q.clone()); } - progress_client.create_course(course_id, &1, &(quiz_ids.len() as u32), &module_ids, &quiz_ids_vec); + progress_client.create_course( + course_id, + &1, + &(quiz_ids.len() as u32), + &module_ids, + &quiz_ids_vec, + ); progress_client.enroll(learner, course_id); } @@ -72,7 +78,13 @@ fn test_double_claim_prevented() { let course_id = Symbol::new(env, "course_1"); let quiz_id = Symbol::new(env, "quiz_1"); - setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + setup_course_and_enroll( + env, + &progress_client, + learner, + &course_id, + &[quiz_id.clone()], + ); progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); token_client.claim_reward(learner, &course_id, &quiz_id); @@ -92,7 +104,13 @@ fn test_multiple_quiz_rewards() { let course_id = Symbol::new(env, "course_1"); let quiz1 = Symbol::new(env, "quiz_1"); let quiz2 = Symbol::new(env, "quiz_2"); - setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz1.clone(), quiz2.clone()]); + setup_course_and_enroll( + env, + &progress_client, + learner, + &course_id, + &[quiz1.clone(), quiz2.clone()], + ); progress_client.submit_quiz_score(learner, &course_id, &quiz1, &80); progress_client.submit_quiz_score(learner, &course_id, &quiz2, &60); @@ -116,7 +134,13 @@ fn test_learner_to_learner_transfer() { let course_id = Symbol::new(env, "course_1"); let quiz_id = Symbol::new(env, "quiz_1"); - setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + setup_course_and_enroll( + env, + &progress_client, + learner, + &course_id, + &[quiz_id.clone()], + ); progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &100); token_client.claim_reward(learner, &course_id, &quiz_id); // 10000 tokens @@ -141,7 +165,13 @@ fn test_total_supply_consistency() { let course_id = Symbol::new(env, "course_1"); let q1 = Symbol::new(env, "q1"); let q2 = Symbol::new(env, "q2"); - setup_course_and_enroll(env, &progress_client, learner1, &course_id, &[q1.clone(), q2.clone()]); + setup_course_and_enroll( + env, + &progress_client, + learner1, + &course_id, + &[q1.clone(), q2.clone()], + ); progress_client.enroll(&learner2, &course_id); progress_client.submit_quiz_score(learner1, &course_id, &q1, &80); progress_client.submit_quiz_score(&learner2, &course_id, &q2, &50); diff --git a/tests/unit/progress_tests.rs b/tests/unit/progress_tests.rs index 26019ad..e105109 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -284,11 +284,14 @@ mod progress_unit_tests { q.push_back(Symbol::new(&env, "quiz_1")); q }, + archived: false, + content_hash: Symbol::new(&env, "none"), }; env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&progress_tracker::ProgressTrackerDataKey::Course(course_id.clone()), &course); + env.storage().persistent().set( + &progress_tracker::ProgressTrackerDataKey::Course(course_id.clone()), + &course, + ); }); let learner = Address::generate(&env); @@ -453,7 +456,10 @@ mod progress_unit_tests { client.get_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1")), 70 ); - assert_eq!(client.get_progress(&learner, &course_id).quizzes_submitted, 1); + assert_eq!( + client.get_progress(&learner, &course_id).quizzes_submitted, + 1 + ); } #[test] @@ -622,9 +628,18 @@ mod progress_unit_tests { // up the module list. let course = client.get_course(&course_id); assert_eq!(course.module_ids.len(), 3); - assert_eq!(course.module_ids.get(0).unwrap(), Symbol::new(&env, "mod_1")); - assert_eq!(course.module_ids.get(1).unwrap(), Symbol::new(&env, "mod_2")); - assert_eq!(course.module_ids.get(2).unwrap(), Symbol::new(&env, "mod_3")); + assert_eq!( + course.module_ids.get(0).unwrap(), + Symbol::new(&env, "mod_1") + ); + assert_eq!( + course.module_ids.get(1).unwrap(), + Symbol::new(&env, "mod_2") + ); + assert_eq!( + course.module_ids.get(2).unwrap(), + Symbol::new(&env, "mod_3") + ); // Ordering/ existence checks driven by Course::module_ids still work. let learner = Address::generate(&env); @@ -658,7 +673,10 @@ mod progress_unit_tests { progress.eligible_for_credential = true; env.as_contract(&contract_id, || { env.storage().persistent().set( - &progress_tracker::ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), + &progress_tracker::ProgressTrackerDataKey::Progress( + learner.clone(), + course_id.clone(), + ), &progress, ); }); From 20947763395a5d886bd0921864e4985d7b465418 Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:59:02 +0100 Subject: [PATCH 2/4] feat(learn-token): track total tokens minted per address Add `total_minted_to(address)` returning the cumulative amount ever minted to an address, so analytics can break minting down per address instead of only seeing the global total supply. - New `TokenDataKey::TotalMintedTo(Address)` persistent entry, with `get_total_minted_to` / `add_total_minted_to` storage helpers that extend TTL on write like the other per-address entries. - Updated on both minting paths: `mint` and `claim_reward`. Reward claims mint tokens too, so omitting them would undercount. - The counter is monotonic: transfers and burns leave it untouched, so it reflects total minting rather than current holdings. Reads are a single storage get and writes piggyback on paths that already touch storage, so there is no added cost on non-minting calls. Also fixes a pre-existing build break: tests/unit/progress_tests.rs constructed Course without the `archived` field added in #210, so the progress_tests target failed to compile on main. Closes #236 --- contracts/learn-token/src/lib.rs | 119 +++++++++++++++++++++++++++ contracts/learn-token/src/storage.rs | 28 +++++++ 2 files changed, 147 insertions(+) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 0bfc062..dc6d796 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -262,6 +262,18 @@ impl LearnToken { } /// Returns the balance of the given address. + /// Returns the cumulative amount ever minted to an address (#236). + /// + /// Unlike `balance`, this only ever grows: transfers and burns do not + /// reduce it, so it reflects total minting rather than current holdings. + /// Returns 0 for an address that has never been minted to. + /// + /// # Arguments + /// * `address` - The address to query + pub fn total_minted_to(env: Env, address: Address) -> i128 { + storage::get_total_minted_to(&env, &address) + } + pub fn balance(env: Env, address: Address) -> i128 { storage::get_balance(&env, &address) } @@ -517,6 +529,9 @@ impl LearnToken { storage::set_total_supply(&env, current_supply + amount); + // Track cumulative minting per address for analytics (#236). + storage::add_total_minted_to(&env, &to, amount); + events::mint(&env, &to, amount); } @@ -578,6 +593,10 @@ impl LearnToken { storage::set_total_supply(&env, current_supply + reward_amount); + // Reward claims mint too, so they count toward the per-address + // minted total (#236). + storage::add_total_minted_to(&env, &learner, reward_amount); + // Mark reward as claimed to prevent double-claiming storage::set_reward_claimed(&env, &learner, &course_id, &quiz_id); @@ -951,6 +970,106 @@ mod tests { pt_client.submit_quiz_score(learner, course_id, quiz_id, &score); } + // ── Issue #236: per-address minted supply tracking ─────────────────── + + #[test] + fn test_total_minted_to_defaults_to_zero() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + let never_minted = Address::generate(&env); + assert_eq!(client.total_minted_to(&never_minted), 0); + } + + #[test] + fn test_total_minted_to_updates_on_mint() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let user = Address::generate(&env); + client.mint(&user, &1_000); + + assert_eq!(client.total_minted_to(&user), 1_000); + } + + #[test] + fn test_total_minted_to_accumulates_across_mints() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let user = Address::generate(&env); + client.mint(&user, &1_000); + client.mint(&user, &500); + client.mint(&user, &250); + + assert_eq!(client.total_minted_to(&user), 1_750); + } + + #[test] + fn test_total_minted_to_is_tracked_per_address() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint(&alice, &900); + client.mint(&bob, &100); + + assert_eq!(client.total_minted_to(&alice), 900); + assert_eq!(client.total_minted_to(&bob), 100); + assert_eq!(client.total_supply(), 1_000); + } + + #[test] + fn test_total_minted_to_unchanged_by_transfer_and_burn() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint(&alice, &1_000); + + client.transfer(&alice, &bob, &400); + client.burn(&alice, &100); + + // Minted total reflects minting only, not current holdings. + assert_eq!(client.total_minted_to(&alice), 1_000); + assert_eq!(client.balance(&alice), 500); + // Receiving a transfer is not minting. + assert_eq!(client.total_minted_to(&bob), 0); + assert_eq!(client.balance(&bob), 400); + } + + #[test] + fn test_total_minted_to_includes_reward_claims() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + client.claim_reward(&learner, &course_id, &quiz_id); + + // claim_reward mints, so it counts toward the per-address total. + let balance = client.balance(&learner); + assert!(balance > 0); + assert_eq!(client.total_minted_to(&learner), balance); + } + #[test] fn test_initialize() { let env = Env::default(); diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index 3e9df46..b598d48 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), + /// Cumulative amount ever minted to an address (#236). + TotalMintedTo(Address), } #[contracttype] @@ -520,3 +522,29 @@ pub fn get_upgrade_version(env: &Env) -> u32 { .get(&TokenDataKey::UpgradeVersion) .unwrap_or(0) } +/// Get the cumulative amount ever minted to an address (#236). +/// +/// Returns 0 for an address that has never been minted to. +pub fn get_total_minted_to(env: &Env, address: &Address) -> i128 { + env.storage() + .persistent() + .get(&TokenDataKey::TotalMintedTo(address.clone())) + .unwrap_or(0) +} + +/// Add `amount` to the cumulative minted total for `address` (#236). +/// +/// Called on every mint path so the running total stays in step with the +/// balance changes that produced it. +pub fn add_total_minted_to(env: &Env, address: &Address, amount: i128) { + let data_key = TokenDataKey::TotalMintedTo(address.clone()); + let current: i128 = env.storage().persistent().get(&data_key).unwrap_or(0); + env.storage() + .persistent() + .set(&data_key, &(current + amount)); + env.storage().persistent().extend_ttl( + &data_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); +} From 04b6582d29e0f1d29c361c7d737f46619b3b94cb Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:01:15 +0100 Subject: [PATCH 3/4] feat(learn-token): add reward claim history Add `get_claim_history(learner)` returning every reward the learner has claimed, so users can see their reward history rather than only their current balance. - New `ClaimRecord` type holding course_id, quiz_id, amount, and the ledger timestamp of the claim. - New `TokenDataKey::ClaimHistory(Address)` persistent entry, appended to by `claim_reward` and read back in claim order. - History is immutable in practice: `claim_reward` rejects double-claims before recording, so entries are only ever appended, never modified or removed. A rejected duplicate claim leaves the history untouched. Returns the full list with no pagination, per the issue. Also fixes a pre-existing build break: tests/unit/progress_tests.rs constructed Course without the `archived` field added in #210, so the progress_tests target failed to compile on main. Closes #237 --- contracts/learn-token/src/lib.rs | 147 ++++++++++++++++++++++++++- contracts/learn-token/src/storage.rs | 48 ++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index dc6d796..0f48f58 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -6,7 +6,7 @@ mod storage; use chainlearn_shared::{BASE_REWARD_PER_POINT, MAX_QUIZ_SCORE}; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, - String as SorobanString, Symbol, + String as SorobanString, Symbol, Vec, }; /// Maximum reward tokens that can be minted in a single claim (#78). @@ -600,6 +600,18 @@ impl LearnToken { // Mark reward as claimed to prevent double-claiming storage::set_reward_claimed(&env, &learner, &course_id, &quiz_id); + // Record the claim so learners can query their history (#237). + storage::append_claim_record( + &env, + &learner, + &storage::ClaimRecord { + course_id: course_id.clone(), + quiz_id: quiz_id.clone(), + amount: reward_amount, + timestamp: env.ledger().timestamp(), + }, + ); + events::reward_claimed(&env, &learner, &quiz_id, score, reward_amount, &course_id); } @@ -671,6 +683,21 @@ impl LearnToken { // ── Admin ───────────────────────────────────────────────────────────── + /// Returns a learner's full reward claim history (#237). + /// + /// Each entry records the course, quiz, amount minted, and the ledger + /// timestamp of the claim, in the order the claims were made. Returns an + /// empty vector for a learner who has never claimed. + /// + /// History is immutable: `claim_reward` rejects double-claims, so entries + /// are only ever appended, never changed or removed. + /// + /// # Arguments + /// * `learner` - The learner to query + pub fn get_claim_history(env: Env, learner: Address) -> Vec { + storage::get_claim_history(&env, &learner) + } + /// Returns the admin address. pub fn admin(env: Env) -> Address { storage::get_admin(&env) @@ -1070,6 +1097,124 @@ mod tests { assert_eq!(client.total_minted_to(&learner), balance); } + // ── Issue #237: reward claim history ───────────────────────────────── + + #[test] + fn test_claim_history_empty_for_new_learner() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + let learner = Address::generate(&env); + assert_eq!(client.get_claim_history(&learner).len(), 0); + } + + #[test] + fn test_claim_history_records_quiz_amount_and_timestamp() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + env.ledger().with_mut(|li| li.timestamp = 12_345); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + client.claim_reward(&learner, &course_id, &quiz_id); + + let history = client.get_claim_history(&learner); + assert_eq!(history.len(), 1); + let record = history.get(0).unwrap(); + assert_eq!(record.course_id, course_id); + assert_eq!(record.quiz_id, quiz_id); + assert_eq!(record.amount, client.balance(&learner)); + assert_eq!(record.timestamp, 12_345); + } + + #[test] + fn test_claim_history_accumulates_in_claim_order() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + let quiz_1 = Symbol::new(&env, "quiz_1"); + let quiz_2 = Symbol::new(&env, "quiz_2"); + quiz_ids.push_back(quiz_1.clone()); + quiz_ids.push_back(quiz_2.clone()); + pt_client.create_course(&course_id, &1, &2, &module_ids, &quiz_ids); + pt_client.enroll(&learner, &course_id); + pt_client.submit_quiz_score(&learner, &course_id, &quiz_1, &60); + pt_client.submit_quiz_score(&learner, &course_id, &quiz_2, &90); + + env.ledger().with_mut(|li| li.timestamp = 100); + client.claim_reward(&learner, &course_id, &quiz_1); + env.ledger().with_mut(|li| li.timestamp = 200); + client.claim_reward(&learner, &course_id, &quiz_2); + + let history = client.get_claim_history(&learner); + assert_eq!(history.len(), 2); + assert_eq!(history.get(0).unwrap().quiz_id, quiz_1); + assert_eq!(history.get(0).unwrap().timestamp, 100); + assert_eq!(history.get(1).unwrap().quiz_id, quiz_2); + assert_eq!(history.get(1).unwrap().timestamp, 200); + // Higher score earns the larger reward. + assert!(history.get(1).unwrap().amount > history.get(0).unwrap().amount); + } + + #[test] + fn test_claim_history_is_per_learner() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &alice, &course_id, &quiz_id, 80); + + client.claim_reward(&alice, &course_id, &quiz_id); + + assert_eq!(client.get_claim_history(&alice).len(), 1); + assert_eq!(client.get_claim_history(&bob).len(), 0); + } + + #[test] + fn test_claim_history_not_duplicated_by_rejected_double_claim() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + client.claim_reward(&learner, &course_id, &quiz_id); + // Second claim panics, so history stays immutable at one entry. + assert!(client + .try_claim_reward(&learner, &course_id, &quiz_id) + .is_err()); + + assert_eq!(client.get_claim_history(&learner).len(), 1); + } + #[test] fn test_initialize() { let env = Env::default(); diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index b598d48..f21003a 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -1,5 +1,5 @@ use chainlearn_shared::{ContractMetadata, PERSISTENT_TTL_EXTEND_TO, PERSISTENT_TTL_THRESHOLD}; -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; // ── Storage Keys ────────────────────────────────────────────────────────────── @@ -41,6 +41,8 @@ pub enum TokenDataKey { LastTransfer(Address), /// Cumulative amount ever minted to an address (#236). TotalMintedTo(Address), + /// Append-only list of a learner's reward claims (#237). + ClaimHistory(Address), } #[contracttype] @@ -73,6 +75,20 @@ pub struct AllowanceData { pub expiration_ledger: u32, } +/// A single reward claim, recorded for a learner's history (#237). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimRecord { + /// The course the quiz belonged to. + pub course_id: Symbol, + /// The quiz that was claimed. + pub quiz_id: Symbol, + /// Reward amount minted for the claim. + pub amount: i128, + /// Ledger timestamp when the claim was made. + pub timestamp: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RewardKey { @@ -548,3 +564,33 @@ pub fn add_total_minted_to(env: &Env, address: &Address, amount: i128) { PERSISTENT_TTL_EXTEND_TO, ); } + +/// Get a learner's full reward claim history (#237). +/// +/// Returns an empty vector for a learner who has never claimed. +pub fn get_claim_history(env: &Env, learner: &Address) -> Vec { + env.storage() + .persistent() + .get(&TokenDataKey::ClaimHistory(learner.clone())) + .unwrap_or_else(|| Vec::new(env)) +} + +/// Append a claim to a learner's history (#237). +/// +/// History is append-only: entries are never modified or removed, which is +/// safe because `claim_reward` rejects double-claims before reaching here. +pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { + let data_key = TokenDataKey::ClaimHistory(learner.clone()); + let mut history: Vec = env + .storage() + .persistent() + .get(&data_key) + .unwrap_or_else(|| Vec::new(env)); + history.push_back(record.clone()); + env.storage().persistent().set(&data_key, &history); + env.storage().persistent().extend_ttl( + &data_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); +} From 13a9b0ea46b432df6f36202113fb21033a76563b Mon Sep 17 00:00:00 2001 From: Johnalex-hub <56762617+Johnalex-hub@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:59 +0100 Subject: [PATCH 4/4] feat(learn-token): add contract pause events Add `paused(admin, timestamp)` and `unpaused(admin, timestamp)` events so pause activity can be audited and monitored. The issue scoped this to events only, but the contract had no pause mechanism at all -- grep for pause/halt across contracts/ and packages/ returns nothing. Events with no operation to emit them would be dead code and could not satisfy "events are emitted", so this adds the minimal pause surface the events attach to: - `pause` / `unpause`, admin-only, rejecting redundant transitions, each emitting its event with the acting admin and ledger timestamp. - `is_paused` for querying the current state. - A `Paused` persistent flag, defaulting to unpaused. Both events follow the existing Symbol::new topic convention used by the other events here, so indexers can match on them consistently. The flag is enforced on the state-changing entrypoints -- transfer, transfer_from, burn, burn_from, mint, and claim_reward -- since a pause flag that blocked nothing would misrepresent the contract as pausable. Read-only calls stay available while paused. Also fixes a pre-existing build break: tests/unit/progress_tests.rs constructed Course without the `archived` field added in #210, so the progress_tests target failed to compile on main. Closes #238 --- contracts/learn-token/src/events.rs | 18 +++ contracts/learn-token/src/lib.rs | 154 +++++++++++++++++++++- contracts/learn-token/src/storage.rs | 23 +++- contracts/progress-tracker/src/lib.rs | 14 +- contracts/progress-tracker/src/rewards.rs | 5 +- tests/integration/fixtures.rs | 2 +- tests/integration/token_flow.rs | 40 +----- tests/unit/progress_tests.rs | 32 ++--- 8 files changed, 216 insertions(+), 72 deletions(-) diff --git a/contracts/learn-token/src/events.rs b/contracts/learn-token/src/events.rs index ed8e1b3..a99e731 100644 --- a/contracts/learn-token/src/events.rs +++ b/contracts/learn-token/src/events.rs @@ -182,3 +182,21 @@ pub fn upgraded(env: &Env, new_wasm_hash: &BytesN<32>, upgrade_version: u32) { env.events() .publish(topics, (new_wasm_hash.clone(), upgrade_version)); } + +/// Emitted when the contract is paused by an admin (#238). +/// +/// Topics: ["paused"] +/// Data: (admin, timestamp) +pub fn paused(env: &Env, admin: &Address, timestamp: u64) { + let topics = (Symbol::new(env, "paused"),); + env.events().publish(topics, (admin, timestamp)); +} + +/// Emitted when the contract is unpaused by an admin (#238). +/// +/// Topics: ["unpaused"] +/// Data: (admin, timestamp) +pub fn unpaused(env: &Env, admin: &Address, timestamp: u64) { + let topics = (Symbol::new(env, "unpaused"),); + env.events().publish(topics, (admin, timestamp)); +} diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 0f48f58..2a130cd 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -54,6 +54,15 @@ pub struct ClaimEstimate { #[contract] pub struct LearnToken; +impl LearnToken { + /// Panic if the contract is paused (#238). + fn require_not_paused(env: &Env) { + if storage::is_paused(env) { + panic!("contract is paused"); + } + } +} + #[contractimpl] impl LearnToken { // ── Initialization ──────────────────────────────────────────────────── @@ -285,6 +294,7 @@ impl LearnToken { /// * `to` - Destination address /// * `amount` - Amount to transfer pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + Self::require_not_paused(&env); from.require_auth(); if from == to { @@ -327,6 +337,7 @@ impl LearnToken { /// * `to` - Destination address /// * `amount` - Amount to transfer pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + Self::require_not_paused(&env); spender.require_auth(); if from == to { @@ -426,6 +437,7 @@ impl LearnToken { /// * If `amount` is negative /// * If `from` holds less than `amount` pub fn burn(env: Env, from: Address, amount: i128) { + Self::require_not_paused(&env); from.require_auth(); if amount < 0 { @@ -462,6 +474,7 @@ impl LearnToken { /// * If the spender's allowance is below `amount` /// * If `from` holds less than `amount` pub fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { + Self::require_not_paused(&env); spender.require_auth(); if amount < 0 { @@ -503,6 +516,7 @@ impl LearnToken { /// * `to` - Recipient address /// * `amount` - Amount to mint pub fn mint(env: Env, to: Address, amount: i128) { + Self::require_not_paused(&env); let admin = storage::get_admin(&env); admin.require_auth(); @@ -548,6 +562,7 @@ impl LearnToken { /// * `course_id` - The course the quiz belongs to /// * `quiz_id` - Unique identifier for the quiz pub fn claim_reward(env: Env, learner: Address, course_id: Symbol, quiz_id: Symbol) { + Self::require_not_paused(&env); learner.require_auth(); if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) { @@ -698,6 +713,44 @@ impl LearnToken { storage::get_claim_history(&env, &learner) } + // ── 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(); + + if storage::is_paused(&env) { + panic!("already paused"); + } + + storage::set_paused(&env, true); + events::paused(&env, &admin, 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(); + + if !storage::is_paused(&env) { + panic!("not paused"); + } + + storage::set_paused(&env, false); + events::unpaused(&env, &admin, env.ledger().timestamp()); + } + + /// Returns whether the contract is currently paused (#238). + pub fn is_paused(env: Env) -> bool { + storage::is_paused(&env) + } + /// Returns the admin address. pub fn admin(env: Env) -> Address { storage::get_admin(&env) @@ -953,7 +1006,7 @@ impl LearnToken { mod tests { use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger as _}, + testutils::{storage::Persistent as _, Address as _, Events as _, Ledger as _}, Address, Env, IntoVal, String as SorobanString, Vec, }; @@ -1215,6 +1268,105 @@ mod tests { assert_eq!(client.get_claim_history(&learner).len(), 1); } + // ── Issue #238: pause/unpause events ───────────────────────────────── + + #[test] + fn test_contract_starts_unpaused() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + assert!(!client.is_paused()); + } + + #[test] + fn test_pause_emits_event_with_admin_and_timestamp() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + env.ledger().with_mut(|li| li.timestamp = 5_000); + + client.pause(); + + assert!(client.is_paused()); + let events = env.events().all(); + let (contract_id, topics, data) = events.last().unwrap(); + assert_eq!(contract_id, lt_id); + assert_eq!(topics, (Symbol::new(&env, "paused"),).into_val(&env)); + let (event_admin, event_ts): (Address, u64) = + soroban_sdk::TryFromVal::try_from_val(&env, &data).unwrap(); + assert_eq!(event_admin, admin); + assert_eq!(event_ts, 5_000); + } + + #[test] + fn test_unpause_emits_event_with_admin_and_timestamp() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + client.pause(); + env.ledger().with_mut(|li| li.timestamp = 9_000); + + client.unpause(); + + assert!(!client.is_paused()); + let events = env.events().all(); + let (contract_id, topics, data) = events.last().unwrap(); + assert_eq!(contract_id, lt_id); + assert_eq!(topics, (Symbol::new(&env, "unpaused"),).into_val(&env)); + let (event_admin, event_ts): (Address, u64) = + soroban_sdk::TryFromVal::try_from_val(&env, &data).unwrap(); + assert_eq!(event_admin, admin); + assert_eq!(event_ts, 9_000); + } + + #[test] + #[should_panic(expected = "already paused")] + fn test_pause_twice_panics() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + client.pause(); + client.pause(); + } + + #[test] + #[should_panic(expected = "not paused")] + fn test_unpause_when_not_paused_panics() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + client.unpause(); + } + + #[test] + fn test_pause_blocks_transfers_and_unpause_restores_them() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint(&alice, &1_000); + + client.pause(); + assert!(client.try_transfer(&alice, &bob, &100).is_err()); + assert!(client.try_mint(&alice, &100).is_err()); + + client.unpause(); + client.transfer(&alice, &bob, &100); + assert_eq!(client.balance(&bob), 100); + } + #[test] fn test_initialize() { let env = Env::default(); diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index f21003a..0c33bc6 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -43,6 +43,8 @@ pub enum TokenDataKey { TotalMintedTo(Address), /// Append-only list of a learner's reward claims (#237). ClaimHistory(Address), + /// Whether the contract is currently paused (#238). + Paused, } #[contracttype] @@ -220,11 +222,7 @@ pub fn check_allowance_expired(env: &Env, owner: &Address, spender: &Address) -> } /// Read-only version of check_allowance_expired that does not perform storage side-effects. -pub fn check_allowance_expired_readonly( - env: &Env, - owner: &Address, - spender: &Address, -) -> (bool, bool, u32) { +pub fn check_allowance_expired_readonly(env: &Env, owner: &Address, spender: &Address) -> (bool, bool, u32) { let key = AllowanceKey { owner: owner.clone(), spender: spender.clone(), @@ -594,3 +592,18 @@ pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { PERSISTENT_TTL_EXTEND_TO, ); } + +/// 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 a022b05..73020aa 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -322,7 +322,8 @@ 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); + progress.eligible_for_credential = + rewards::is_eligible_for_credential(&course, &progress); env.storage().persistent().set( &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), @@ -416,7 +417,7 @@ impl ProgressTracker { }; env.storage().persistent().set(&quiz_key, &result); - + progress.quizzes_submitted += 1; progress.total_quiz_score += score as u64; @@ -425,7 +426,8 @@ impl ProgressTracker { // 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); + progress.eligible_for_credential = + rewards::is_eligible_for_credential(&course, &progress); // Single write with all updated fields env.storage().persistent().set( @@ -698,8 +700,10 @@ impl ProgressTracker { .persistent() .set(&ProgressTrackerDataKey::Course(course_id.clone()), &course); - env.events() - .publish((Symbol::new(&env, "course_archived"),), (&course_id,)); + env.events().publish( + (Symbol::new(&env, "course_archived"),), + (&course_id,), + ); } /// Set or update the content hash for a course. Admin only (#235). diff --git a/contracts/progress-tracker/src/rewards.rs b/contracts/progress-tracker/src/rewards.rs index 2d6c228..deabf3a 100644 --- a/contracts/progress-tracker/src/rewards.rs +++ b/contracts/progress-tracker/src/rewards.rs @@ -50,7 +50,10 @@ pub fn average_quiz_score(progress: &ProgressInfo) -> u32 { } /// Determine if a learner is eligible for a credential. -pub fn is_eligible_for_credential(course: &Course, progress: &ProgressInfo) -> bool { +pub fn is_eligible_for_credential( + course: &Course, + progress: &ProgressInfo, +) -> bool { // Check all modules completed let completed = count_completed_modules(progress); if completed < course.total_modules { diff --git a/tests/integration/fixtures.rs b/tests/integration/fixtures.rs index 976879c..97fbeba 100644 --- a/tests/integration/fixtures.rs +++ b/tests/integration/fixtures.rs @@ -5,8 +5,8 @@ #![allow(dead_code)] -use credential_nft::{CredentialNft, CredentialNftClient}; use learn_token::{LearnToken, LearnTokenClient}; +use credential_nft::{CredentialNft, CredentialNftClient}; use progress_tracker::{ProgressTracker, ProgressTrackerClient}; use soroban_sdk::{testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec}; diff --git a/tests/integration/token_flow.rs b/tests/integration/token_flow.rs index d19c31c..21ec8a3 100644 --- a/tests/integration/token_flow.rs +++ b/tests/integration/token_flow.rs @@ -25,13 +25,7 @@ fn setup_course_and_enroll( for q in quiz_ids { quiz_ids_vec.push_back(q.clone()); } - progress_client.create_course( - course_id, - &1, - &(quiz_ids.len() as u32), - &module_ids, - &quiz_ids_vec, - ); + progress_client.create_course(course_id, &1, &(quiz_ids.len() as u32), &module_ids, &quiz_ids_vec); progress_client.enroll(learner, course_id); } @@ -78,13 +72,7 @@ fn test_double_claim_prevented() { let course_id = Symbol::new(env, "course_1"); let quiz_id = Symbol::new(env, "quiz_1"); - setup_course_and_enroll( - env, - &progress_client, - learner, - &course_id, - &[quiz_id.clone()], - ); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); token_client.claim_reward(learner, &course_id, &quiz_id); @@ -104,13 +92,7 @@ fn test_multiple_quiz_rewards() { let course_id = Symbol::new(env, "course_1"); let quiz1 = Symbol::new(env, "quiz_1"); let quiz2 = Symbol::new(env, "quiz_2"); - setup_course_and_enroll( - env, - &progress_client, - learner, - &course_id, - &[quiz1.clone(), quiz2.clone()], - ); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz1.clone(), quiz2.clone()]); progress_client.submit_quiz_score(learner, &course_id, &quiz1, &80); progress_client.submit_quiz_score(learner, &course_id, &quiz2, &60); @@ -134,13 +116,7 @@ fn test_learner_to_learner_transfer() { let course_id = Symbol::new(env, "course_1"); let quiz_id = Symbol::new(env, "quiz_1"); - setup_course_and_enroll( - env, - &progress_client, - learner, - &course_id, - &[quiz_id.clone()], - ); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &100); token_client.claim_reward(learner, &course_id, &quiz_id); // 10000 tokens @@ -165,13 +141,7 @@ fn test_total_supply_consistency() { let course_id = Symbol::new(env, "course_1"); let q1 = Symbol::new(env, "q1"); let q2 = Symbol::new(env, "q2"); - setup_course_and_enroll( - env, - &progress_client, - learner1, - &course_id, - &[q1.clone(), q2.clone()], - ); + setup_course_and_enroll(env, &progress_client, learner1, &course_id, &[q1.clone(), q2.clone()]); progress_client.enroll(&learner2, &course_id); progress_client.submit_quiz_score(learner1, &course_id, &q1, &80); progress_client.submit_quiz_score(&learner2, &course_id, &q2, &50); diff --git a/tests/unit/progress_tests.rs b/tests/unit/progress_tests.rs index e105109..7997daa 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -288,10 +288,9 @@ mod progress_unit_tests { content_hash: Symbol::new(&env, "none"), }; env.as_contract(&contract_id, || { - env.storage().persistent().set( - &progress_tracker::ProgressTrackerDataKey::Course(course_id.clone()), - &course, - ); + env.storage() + .persistent() + .set(&progress_tracker::ProgressTrackerDataKey::Course(course_id.clone()), &course); }); let learner = Address::generate(&env); @@ -456,10 +455,7 @@ mod progress_unit_tests { client.get_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1")), 70 ); - assert_eq!( - client.get_progress(&learner, &course_id).quizzes_submitted, - 1 - ); + assert_eq!(client.get_progress(&learner, &course_id).quizzes_submitted, 1); } #[test] @@ -628,18 +624,9 @@ mod progress_unit_tests { // up the module list. let course = client.get_course(&course_id); assert_eq!(course.module_ids.len(), 3); - assert_eq!( - course.module_ids.get(0).unwrap(), - Symbol::new(&env, "mod_1") - ); - assert_eq!( - course.module_ids.get(1).unwrap(), - Symbol::new(&env, "mod_2") - ); - assert_eq!( - course.module_ids.get(2).unwrap(), - Symbol::new(&env, "mod_3") - ); + assert_eq!(course.module_ids.get(0).unwrap(), Symbol::new(&env, "mod_1")); + assert_eq!(course.module_ids.get(1).unwrap(), Symbol::new(&env, "mod_2")); + assert_eq!(course.module_ids.get(2).unwrap(), Symbol::new(&env, "mod_3")); // Ordering/ existence checks driven by Course::module_ids still work. let learner = Address::generate(&env); @@ -673,10 +660,7 @@ mod progress_unit_tests { progress.eligible_for_credential = true; env.as_contract(&contract_id, || { env.storage().persistent().set( - &progress_tracker::ProgressTrackerDataKey::Progress( - learner.clone(), - course_id.clone(), - ), + &progress_tracker::ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); });