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 0bfc062..2a130cd 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). @@ -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 ──────────────────────────────────────────────────── @@ -262,6 +271,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) } @@ -273,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 { @@ -315,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 { @@ -414,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 { @@ -450,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 { @@ -491,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(); @@ -517,6 +543,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); } @@ -533,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) { @@ -578,9 +608,25 @@ 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); + // 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); } @@ -652,6 +698,59 @@ 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) + } + + // ── 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) @@ -907,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, }; @@ -951,6 +1050,323 @@ 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); + } + + // ── 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); + } + + // ── 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 fdee519..0c33bc6 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 ────────────────────────────────────────────────────────────── @@ -39,6 +39,12 @@ 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), + /// Append-only list of a learner's reward claims (#237). + ClaimHistory(Address), + /// Whether the contract is currently paused (#238). + Paused, } #[contracttype] @@ -71,6 +77,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 { @@ -516,3 +536,74 @@ 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, + ); +} + +/// 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, + ); +} + +/// 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 3052325..73020aa 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) { @@ -672,6 +706,55 @@ impl ProgressTracker { ); } + /// 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, "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 +866,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/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/unit/progress_tests.rs b/tests/unit/progress_tests.rs index 26019ad..7997daa 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -284,6 +284,8 @@ 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()