From 8b98f871378afa2c8d3f3b113275dd28b152553e Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 00:50:05 +0100 Subject: [PATCH 1/5] test: add integration tests for cross-contract interactions --- Cargo.toml | 4 +++ tests/integration/full_flow.rs | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/integration/full_flow.rs 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/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); +} From 0107eafc23a3e320d60223ef729c945a7eecb2f6 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 00:50:06 +0100 Subject: [PATCH 2/5] feat: add emergency pause mechanism --- contracts/credential-nft/src/lib.rs | 31 +++++++++++++++++++++++ contracts/credential-nft/src/metadata.rs | 2 ++ contracts/progress-tracker/src/lib.rs | 32 ++++++++++++++++++++++++ contracts/progress-tracker/src/types.rs | 3 +++ tests/unit/progress_tests.rs | 1 + 5 files changed, 69 insertions(+) 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/progress-tracker/src/lib.rs b/contracts/progress-tracker/src/lib.rs index 3052325..199dcae 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -87,6 +87,7 @@ impl ProgressTracker { module_ids: Vec, quiz_ids: Vec, ) { + Self::require_not_paused(&env); let admin: Address = env .storage() .persistent() @@ -166,6 +167,7 @@ impl ProgressTracker { /// assert!(!progress.eligible_for_credential); /// ``` pub fn enroll(env: Env, learner: Address, course_id: Symbol) { + Self::require_not_paused(&env); learner.require_auth(); // Verify course exists @@ -227,6 +229,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 @@ -338,6 +341,7 @@ impl ProgressTracker { quiz_id: Symbol, score: u32, ) { + Self::require_not_paused(&env); learner.require_auth(); if score > chainlearn_shared::MAX_QUIZ_SCORE { @@ -644,6 +648,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() @@ -686,6 +691,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 f919f7a..3589d3c 100644 --- a/contracts/progress-tracker/src/types.rs +++ b/contracts/progress-tracker/src/types.rs @@ -91,4 +91,7 @@ pub enum ProgressTrackerDataKey { QuizResult(Address, Symbol, Symbol), /// On-chain contract name/version, set on `initialize()` (#107). Metadata, + /// Emergency pause state (#189). + Paused, } + diff --git a/tests/unit/progress_tests.rs b/tests/unit/progress_tests.rs index 26019ad..121a913 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -284,6 +284,7 @@ mod progress_unit_tests { q.push_back(Symbol::new(&env, "quiz_1")); q }, + archived: false, }; env.as_contract(&contract_id, || { env.storage() From b62e7b3b9d2147f929de9e6a0894e89ee6a0cc77 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 00:50:06 +0100 Subject: [PATCH 3/5] feat: separate admin roles into admin, minter, and pauser --- contracts/learn-token/src/events.rs | 46 +++++++++++++++ contracts/learn-token/src/storage.rs | 83 ++++++++++++++++++++++++++++ tests/unit/token_tests.rs | 62 ++++++++++----------- 3 files changed, 160 insertions(+), 31 deletions(-) diff --git a/contracts/learn-token/src/events.rs b/contracts/learn-token/src/events.rs index ed8e1b3..63244a4 100644 --- a/contracts/learn-token/src/events.rs +++ b/contracts/learn-token/src/events.rs @@ -182,3 +182,49 @@ 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 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 (#189). +/// +/// Topics: ["paused", caller] +/// Data: () +pub fn paused(env: &Env, caller: &Address) { + let topics = (Symbol::new(env, "paused"), caller.clone()); + env.events().publish(topics, ()); +} + +/// Emitted when the contract is unpaused (#189). +/// +/// Topics: ["unpaused", caller] +/// Data: () +pub fn unpaused(env: &Env, caller: &Address) { + let topics = (Symbol::new(env, "unpaused"), caller.clone()); + env.events().publish(topics, ()); +} diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index fdee519..0d8e926 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -39,8 +39,28 @@ 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), + /// Emergency pause state (#189). + 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 { @@ -99,6 +119,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() 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); From b14a74d19f3ec17db7358707ddccb73a4496994a Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 00:50:06 +0100 Subject: [PATCH 4/5] feat: implement batch claim reward --- contracts/learn-token/src/lib.rs | 179 +++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 20 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 0bfc062..bbf7943 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -275,6 +275,8 @@ impl LearnToken { pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { from.require_auth(); + Self::require_not_paused(&env); + if from == to { return; } @@ -317,6 +319,8 @@ impl LearnToken { pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { spender.require_auth(); + Self::require_not_paused(&env); + if from == to { return; } @@ -416,6 +420,8 @@ impl LearnToken { pub fn burn(env: Env, from: Address, amount: i128) { from.require_auth(); + Self::require_not_paused(&env); + if amount < 0 { panic!("negative amount"); } @@ -452,6 +458,8 @@ impl LearnToken { pub fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { spender.require_auth(); + Self::require_not_paused(&env); + if amount < 0 { panic!("negative amount"); } @@ -490,9 +498,15 @@ impl LearnToken { /// # Arguments /// * `to` - Recipient address /// * `amount` - Amount to mint - pub fn mint(env: Env, to: Address, amount: i128) { - let admin = storage::get_admin(&env); - admin.require_auth(); + pub fn mint(env: Env, caller: Address, to: Address, amount: i128) { + caller.require_auth(); + + Self::require_not_paused(&env); + if !storage::has_role(&env, &caller, &storage::AdminRole::Minter) { + panic!("not authorized"); + } + + let zero_address = Address::from_string(&SorobanString::from_str( &env, @@ -535,6 +549,8 @@ impl LearnToken { pub fn claim_reward(env: Env, learner: Address, course_id: Symbol, quiz_id: Symbol) { learner.require_auth(); + Self::require_not_paused(&env); + if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) { panic!("reward already claimed"); } @@ -584,6 +600,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). /// @@ -650,9 +734,64 @@ impl LearnToken { } } + + // ── Emergency Pause (#189) ──────────────────────────────────────────── + + fn require_not_paused(env: &Env) { + if storage::is_paused(env) { + panic!("contract is paused"); + } + } + + /// Pause all state-changing operations. Admin or Pauser only. + pub fn emergency_pause(env: Env, caller: Address) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { + panic!("not authorized"); + } + storage::set_paused(&env, true); + events::paused(&env, &caller); + } + + /// Unpause state-changing operations. Admin or Pauser only. + pub fn unpause(env: Env, caller: Address) { + caller.require_auth(); + if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { + panic!("not authorized"); + } + storage::set_paused(&env, false); + events::unpaused(&env, &caller); + } + // ── Admin ───────────────────────────────────────────────────────────── - /// Returns the admin address. + + /// 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. pub fn admin(env: Env) -> Address { storage::get_admin(&env) } @@ -989,7 +1128,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); @@ -1005,7 +1144,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); @@ -1155,7 +1294,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); } @@ -1171,7 +1310,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 @@ -1256,7 +1395,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); @@ -1272,7 +1411,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); @@ -1288,7 +1427,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); @@ -1305,7 +1444,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); } @@ -1319,7 +1458,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); } @@ -1332,7 +1471,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(&[]); @@ -1349,7 +1488,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); @@ -1370,7 +1509,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); @@ -1387,7 +1526,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); @@ -1405,7 +1544,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); } @@ -1420,7 +1559,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); @@ -1730,7 +1869,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. From 1dc1e6ab7f7796b7bb635015382cf098d5627850 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:19:46 +0100 Subject: [PATCH 5/5] Refactor pause to use Pauser role --- contracts/learn-token/src/lib.rs | 53 +++++++++----------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index ae8182e..ee8fabb 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -779,31 +779,7 @@ impl LearnToken { // ── Emergency Pause (#189) ──────────────────────────────────────────── - fn require_not_paused(env: &Env) { - if storage::is_paused(env) { - panic!("contract is paused"); - } - } - /// Pause all state-changing operations. Admin or Pauser only. - pub fn emergency_pause(env: Env, caller: Address) { - caller.require_auth(); - if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { - panic!("not authorized"); - } - storage::set_paused(&env, true); - events::paused(&env, &caller); - } - - /// Unpause state-changing operations. Admin or Pauser only. - pub fn unpause(env: Env, caller: Address) { - caller.require_auth(); - if !storage::has_role(&env, &caller, &storage::AdminRole::Pauser) { - panic!("not authorized"); - } - storage::set_paused(&env, false); - events::unpaused(&env, &caller); - } // ── Admin ───────────────────────────────────────────────────────────── @@ -851,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).