From d76003b6214b10f149732bc1ab05d8088113eb88 Mon Sep 17 00:00:00 2001 From: markdavid000 Date: Mon, 31 Aug 2026 05:44:01 +0100 Subject: [PATCH 1/4] test: add security tests for double-spending prevention (#289) Verifies that double-spending is impossible across all contracts: - Double-claim reward is prevented with clear error message - Double-mint credential is prevented with clear error message - Double-enroll is prevented with clear error message - Double-complete module and double-submit quiz are prevented - Double-revoke credential is prevented - State is not corrupted after failed double-spend attempts - Cross-contract operations (claim + mint) are independent All error messages are clear and specific. --- Cargo.toml | 4 + .../security_double_spending_tests.rs | 416 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 tests/integration/security_double_spending_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 03d22ca..1dddec5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,10 @@ path = "tests/integration/admin_role_flow.rs" name = "batch_operations" path = "tests/integration/batch_operations.rs" +[[test]] +name = "security_double_spending_tests" +path = "tests/integration/security_double_spending_tests.rs" + [[test]] name = "xcontract_calls_bench" path = "tests/benchmarks/xcontract_calls.rs" diff --git a/tests/integration/security_double_spending_tests.rs b/tests/integration/security_double_spending_tests.rs new file mode 100644 index 0000000..7dc4ac1 --- /dev/null +++ b/tests/integration/security_double_spending_tests.rs @@ -0,0 +1,416 @@ +#![cfg(test)] + +//! Security tests verifying that double-spending is prevented across all +//! ChainLearn contracts. Double-spending is a critical vulnerability — these +//! tests prove it is impossible through every contract surface. + +use credential_nft::{CredentialNft, CredentialNftClient}; +use learn_token::LearnTokenClient; +use progress_tracker::{ProgressTracker, ProgressTrackerClient}; +use soroban_sdk::{ + testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec, +}; + +fn setup_env(env: &Env) -> (Address, LearnTokenClient<'static>, CredentialNftClient<'static>, ProgressTrackerClient<'static>) { + let admin = Address::generate(env); + + // Register and initialize ProgressTracker + let progress_contract_id = env.register_contract(None, ProgressTracker); + let progress_client = ProgressTrackerClient::new(env, &progress_contract_id); + progress_client.initialize(&admin); + + // Register and initialize LearnToken + let token_contract_id = env.register_contract(None, learn_token::LearnToken); + let token_client = LearnTokenClient::new(env, &token_contract_id); + token_client.initialize( + &admin, + &SorobanString::from_str(env, "CLearn"), + &SorobanString::from_str(env, "CLRN"), + &7, + &progress_contract_id, + &1_000_000_000_000_000, + ); + + // Register and initialize CredentialNft + let credential_contract_id = env.register_contract(None, CredentialNft); + let credential_client = CredentialNftClient::new(env, &credential_contract_id); + credential_client.initialize(&admin, &progress_contract_id); + + (admin, token_client, credential_client, progress_client) +} + +fn create_course_and_complete( + env: &Env, + progress_client: &ProgressTrackerClient, + learner: &Address, + course_id: &Symbol, + score: u32, +) { + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + module_ids.push_back(Symbol::new(env, "mod_2")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + progress_client.create_course(course_id, &2, &1, &module_ids, &quiz_ids); + progress_client.enroll(learner, course_id); + progress_client.complete_module(learner, course_id, &Symbol::new(env, "mod_1")); + progress_client.complete_module(learner, course_id, &Symbol::new(env, "mod_2")); + progress_client.submit_quiz_score(learner, course_id, &Symbol::new(env, "quiz_1"), &score); +} + +// ── Double-claim reward ───────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "reward already claimed")] +fn test_double_claim_reward_is_prevented() { + let env = Env::default(); + let (_admin, token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 80); + + // First claim succeeds + token_client.claim_reward(&learner, &course_id, &Symbol::new(env, "quiz_1")); + + // Second claim for the same quiz must be rejected + token_client.claim_reward(&learner, &course_id, &Symbol::new(env, "quiz_1")); +} + +#[test] +fn test_double_claim_reward_does_not_corrupt_state() { + let env = Env::default(); + let (_admin, token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 80); + + // First claim succeeds + token_client.claim_reward(&learner, &course_id, &Symbol::new(env, "quiz_1")); + let balance_after_first = token_client.balance(&learner); + let supply_after_first = token_client.total_supply(); + + // Second claim reverts — state must be unchanged + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + token_client.claim_reward(&learner, &course_id, &Symbol::new(env, "quiz_1")); + })); + assert!(result.is_err(), "double claim should revert"); + + assert_eq!(token_client.balance(&learner), balance_after_first); + assert_eq!(token_client.total_supply(), supply_after_first); +} + +#[test] +fn test_double_claim_different_quizzes_succeeds() { + let env = Env::default(); + let (_admin, token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + quiz_ids.push_back(Symbol::new(&env, "quiz_2")); + progress_client.create_course(&course_id, &1, &2, &module_ids, &quiz_ids); + progress_client.enroll(&learner, &course_id); + progress_client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_1")); + progress_client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1"), &80); + progress_client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_2"), &90); + + // Claiming rewards for two different quizzes is valid — not double-spending + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_1")); + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_2")); + + // 80*100 + 90*100 = 8000 + 9000 = 17000 + assert_eq!(token_client.balance(&learner), 17000); +} + +#[test] +#[should_panic(expected = "reward already claimed")] +fn test_double_claim_in_batch_is_skipped() { + let env = Env::default(); + let (_admin, token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 80); + + // Claim once via single call + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_1")); + + // Attempting to claim again via batch — batch skips already-claimed quizzes, + // so if it accidentally re-mints, the supply cap will be the only guard. + // In this case the batch should simply skip the quiz. Verify with a second + // quiz that hasn't been claimed yet. + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + let successful = token_client.batch_claim_reward(&learner, &course_id, &quiz_ids); + // The already-claimed quiz is skipped, not re-claimed + assert_eq!(successful.len(), 0); + assert_eq!(token_client.balance(&learner), 8000); +} + +// ── Double-mint credential ────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "credential already exists for this learner and course")] +fn test_double_mint_credential_is_prevented() { + let env = Env::default(); + let (_admin, _token_client, credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let metadata_uri = Symbol::new(&env, "ipfs_Qm123"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 85); + + // First mint succeeds + credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + + // Second mint for the same learner+course must be rejected + credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); +} + +#[test] +fn test_double_mint_credential_does_not_corrupt_state() { + let env = Env::default(); + let (_admin, _token_client, credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let metadata_uri = Symbol::new(&env, "ipfs_Qm123"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 85); + + let cred_id = credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + let total_before = credential_client.get_total_credentials_count(); + + // Second mint reverts — state must be unchanged + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + })); + assert!(result.is_err(), "double mint should revert"); + + assert_eq!(credential_client.get_total_credentials_count(), total_before); + let info = credential_client.verify_credential(&cred_id); + assert_eq!(info.learner, learner); + assert_eq!(info.score, 85); +} + +#[test] +fn test_mint_different_courses_succeeds() { + let env = Env::default(); + let (_admin, _token_client, credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_a = Symbol::new(&env, "course_a"); + let course_b = Symbol::new(&env, "course_b"); + let metadata_uri = Symbol::new(&env, "ipfs_Qm123"); + + create_course_and_complete(&env, &progress_client, &learner, &course_a, 85); + + // Create and complete course_b + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + progress_client.create_course(&course_b, &1, &1, &module_ids, &quiz_ids); + progress_client.enroll(&learner, &course_b); + progress_client.complete_module(&learner, &course_b, &Symbol::new(&env, "mod_1")); + progress_client.submit_quiz_score(&learner, &course_b, &Symbol::new(&env, "quiz_1"), &90); + + // Two different courses — not double-spending + credential_client.mint_credential(&learner, &course_a, &85, &metadata_uri); + credential_client.mint_credential(&learner, &course_b, &90, &metadata_uri); + + assert_eq!(credential_client.get_credential_count(&learner), 2); +} + +// ── Double-enroll ─────────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "already enrolled")] +fn test_double_enroll_is_prevented() { + let env = Env::default(); + let (_admin, _token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + // First enrollment succeeds + progress_client.enroll(&learner, &course_id); + + // Second enrollment for the same course must be rejected + progress_client.enroll(&learner, &course_id); +} + +#[test] +fn test_double_enroll_does_not_corrupt_state() { + let env = Env::default(); + let (_admin, _token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + progress_client.enroll(&learner, &course_id); + let progress_before = progress_client.get_progress(&learner, &course_id); + + // Second enrollment reverts — state must be unchanged + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + progress_client.enroll(&learner, &course_id); + })); + assert!(result.is_err(), "double enroll should revert"); + + let progress_after = progress_client.get_progress(&learner, &course_id); + assert_eq!( + progress_before.overall_progress, progress_after.overall_progress + ); + assert_eq!( + progress_before.modules_completed_bitmap, + progress_after.modules_completed_bitmap + ); +} + +#[test] +fn test_enroll_different_courses_succeeds() { + let env = Env::default(); + let (_admin, _token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_a = Symbol::new(&env, "course_a"); + let course_b = Symbol::new(&env, "course_b"); + + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + + progress_client.create_course(&course_a, &1, &1, &module_ids, &quiz_ids); + progress_client.create_course(&course_b, &1, &1, &module_ids, &quiz_ids); + + // Enrolling in two different courses is valid — not double-spending + progress_client.enroll(&learner, &course_a); + progress_client.enroll(&learner, &course_b); + + let courses = progress_client.get_learner_courses(&learner); + assert_eq!(courses.len(), 2); +} + +// ── Double-complete module ────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "module already completed")] +fn test_double_complete_module_is_prevented() { + let env = Env::default(); + let (_admin, _token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + progress_client.enroll(&learner, &course_id); + + // First completion succeeds + progress_client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_1")); + + // Second completion for the same module must be rejected + progress_client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_1")); +} + +// ── Double-submit quiz ────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "quiz already submitted")] +fn test_double_submit_quiz_is_prevented() { + let env = Env::default(); + let (_admin, _token_client, _credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "quiz_1")); + progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + progress_client.enroll(&learner, &course_id); + + // First submission succeeds + progress_client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1"), &80); + + // Second submission for the same quiz must be rejected + progress_client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1"), &90); +} + +// ── Double-revoke credential ──────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "credential already revoked")] +fn test_double_revoke_credential_is_prevented() { + let env = Env::default(); + let (_admin, _token_client, credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let metadata_uri = Symbol::new(&env, "ipfs_Qm123"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 85); + + let cred_id = credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + + // First revocation succeeds + credential_client.revoke_credential(&cred_id); + + // Second revocation must be rejected + credential_client.revoke_credential(&cred_id); +} + +// ── Cross-contract double-spend: claim reward then mint credential ────────── + +#[test] +fn test_claim_reward_then_mint_credential_are_independent() { + let env = Env::default(); + let (_admin, token_client, credential_client, progress_client) = setup_env(&env); + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let metadata_uri = Symbol::new(&env, "ipfs_Qm123"); + create_course_and_complete(&env, &progress_client, &learner, &course_id, 85); + + // Claim reward (mints tokens) + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "quiz_1")); + assert_eq!(token_client.balance(&learner), 8500); + + // Mint credential (mints NFT) — independent operation, not double-spending + credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + assert!(credential_client.is_credential_valid(&1)); + + // Both succeeded — they are different contract surfaces + assert_eq!(token_client.total_supply(), 8500); + assert_eq!(credential_client.get_credential_count(&learner), 1); +} From c94b2cdbada0e12315c856894e16c917431cdf43 Mon Sep 17 00:00:00 2001 From: markdavid000 Date: Mon, 31 Aug 2026 05:45:59 +0100 Subject: [PATCH 2/4] test: add unit tests for cross-contract calls (#287) Tests verify learn-token -> progress-tracker cross-contract calls: - claim_reward correctly fetches score and calculates reward - Score 0 (unsubmitted quiz) causes cross-contract call failure - Non-existent quiz and course produce clear error messages - Failed cross-contract calls do not corrupt token state - batch_claim_reward handles mixed success and failure gracefully - Progress-tracker state is unaffected by token claims - Cross-contract calls are isolated per learner --- Cargo.toml | 4 + tests/unit/xcontract_call_tests.rs | 307 +++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 tests/unit/xcontract_call_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 1dddec5..efa6d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,10 @@ path = "tests/integration/batch_operations.rs" name = "security_double_spending_tests" path = "tests/integration/security_double_spending_tests.rs" +[[test]] +name = "xcontract_call_tests" +path = "tests/unit/xcontract_call_tests.rs" + [[test]] name = "xcontract_calls_bench" path = "tests/benchmarks/xcontract_calls.rs" diff --git a/tests/unit/xcontract_call_tests.rs b/tests/unit/xcontract_call_tests.rs new file mode 100644 index 0000000..d54f811 --- /dev/null +++ b/tests/unit/xcontract_call_tests.rs @@ -0,0 +1,307 @@ +//! Unit tests for cross-contract calls between learn-token and progress-tracker. +//! +//! Verifies that learn-token correctly calls into progress-tracker to fetch +//! quiz scores, handles various response scenarios, and maintains consistent +//! state when cross-contract calls fail. + +use learn_token::LearnTokenClient; +use progress_tracker::{ProgressTracker, ProgressTrackerClient}; +use soroban_sdk::{ + testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec, +}; + +#[cfg(test)] +mod xcontract_call_tests { + use super::*; + + fn setup(env: &Env) -> (Address, Address, Address) { + let admin = Address::generate(env); + + let pt_contract_id = env.register_contract(None, ProgressTracker); + let pt_client = ProgressTrackerClient::new(env, &pt_contract_id); + pt_client.initialize(&admin); + + let token_contract_id = env.register_contract(None, learn_token::LearnToken); + let token_client = LearnTokenClient::new(env, &token_contract_id); + token_client.initialize( + &admin, + &SorobanString::from_str(env, "CLearn"), + &SorobanString::from_str(env, "CLRN"), + &7, + &pt_contract_id, + &1_000_000_000_000_000, + ); + + (admin, token_contract_id, pt_contract_id) + } + + fn create_course_and_submit_quiz( + env: &Env, + pt_client: &ProgressTrackerClient, + learner: &Address, + course_id: &Symbol, + quiz_id: &Symbol, + score: u32, + ) { + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(quiz_id.clone()); + pt_client.create_course(course_id, &1, &1, &module_ids, &quiz_ids); + pt_client.enroll(learner, course_id); + pt_client.submit_quiz_score(learner, course_id, quiz_id, &score); + } + + // ── Successful cross-contract calls ────────────────────────────────── + + #[test] + fn test_claim_reward_fetches_score_from_progress_tracker() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + // claim_reward calls get_quiz_score on progress-tracker via + // env.invoke_contract. Score 80 * BASE_REWARD_PER_POINT (100) = 8000. + token_client.claim_reward(&learner, &course_id, &quiz_id); + assert_eq!(token_client.balance(&learner), 8000); + assert_eq!(token_client.total_supply(), 8000); + } + + #[test] + fn test_cross_contract_call_uses_correct_score_for_reward() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 100); + + token_client.claim_reward(&learner, &course_id, &quiz_id); + // Score 100 * 100 = 10000 + assert_eq!(token_client.balance(&learner), 10000); + } + + #[test] + fn test_cross_contract_call_with_low_score() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 1); + + token_client.claim_reward(&learner, &course_id, &quiz_id); + // Score 1 * 100 = 100 + assert_eq!(token_client.balance(&learner), 100); + } + + // ── Cross-contract call failures ───────────────────────────────────── + + #[test] + #[should_panic(expected = "quiz not submitted")] + fn test_claim_reward_fails_when_quiz_not_submitted() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(quiz_id.clone()); + pt_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + pt_client.enroll(&learner, &course_id); + // Do NOT submit the quiz score — the cross-contract call will fail + + token_client.claim_reward(&learner, &course_id, &quiz_id); + } + + #[test] + #[should_panic(expected = "quiz not submitted")] + fn test_claim_reward_fails_when_quiz_does_not_exist_in_course() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "real_quiz"); + let fake_quiz_id = Symbol::new(&env, "fake_quiz"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + // Try to claim for a quiz_id that was never submitted — the + // cross-contract get_quiz_score call returns an error + token_client.claim_reward(&learner, &course_id, &fake_quiz_id); + } + + #[test] + #[should_panic(expected = "course not found")] + fn test_claim_reward_fails_when_course_does_not_exist() { + let env = Env::default(); + let (_admin, token_id, _pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "nonexistent_course"); + let quiz_id = Symbol::new(&env, "quiz_1"); + + token_client.claim_reward(&learner, &course_id, &quiz_id); + } + + // ── State consistency after failed cross-contract calls ────────────── + + #[test] + fn test_failed_claim_does_not_corrupt_token_state() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + let supply_before = token_client.total_supply(); + let balance_before = token_client.balance(&learner); + + // Attempt to claim for a non-existent quiz — cross-contract call fails + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + token_client.claim_reward(&learner, &course_id, &Symbol::new(&env, "nonexistent")); + })); + assert!(result.is_err(), "cross-contract failure should revert"); + + // Token state is unchanged + assert_eq!(token_client.total_supply(), supply_before); + assert_eq!(token_client.balance(&learner), balance_before); + } + + #[test] + fn test_batch_claim_handles_mixed_success_and_failure() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids_src = Vec::new(&env); + quiz_ids_src.push_back(Symbol::new(&env, "quiz_real")); + quiz_ids_src.push_back(Symbol::new(&env, "quiz_fake")); + pt_client.create_course(&course_id, &1, &2, &module_ids, &quiz_ids_src); + pt_client.enroll(&learner, &course_id); + pt_client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_real"), &80); + // quiz_fake is NOT submitted — the cross-contract call will return 0 + + let mut claim_ids = Vec::new(&env); + claim_ids.push_back(Symbol::new(&env, "quiz_real")); + claim_ids.push_back(Symbol::new(&env, "quiz_fake")); + + let successful = token_client.batch_claim_reward(&learner, &course_id, &claim_ids); + + // quiz_real succeeds, quiz_fake is skipped (score 0 from cross-contract) + assert_eq!(successful.len(), 1); + assert_eq!(token_client.balance(&learner), 8000); + } + + // ── Cross-contract state isolation ─────────────────────────────────── + + #[test] + fn test_progress_tracker_state_unaffected_by_claim() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + // Record progress-tracker state before claim + let progress_before = pt_client.get_progress(&learner, &course_id); + let score_before = pt_client.get_quiz_score(&learner, &course_id, &quiz_id); + + // Claim reward — crosses into progress-tracker but should not modify it + token_client.claim_reward(&learner, &course_id, &quiz_id); + + // Progress-tracker state is unchanged + let progress_after = pt_client.get_progress(&learner, &course_id); + let score_after = pt_client.get_quiz_score(&learner, &course_id, &quiz_id); + assert_eq!(progress_before.overall_progress, progress_after.overall_progress); + assert_eq!(progress_before.quizzes_submitted, progress_after.quizzes_submitted); + assert_eq!(score_before, score_after); + } + + // ── Multiple learners, same course ─────────────────────────────────── + + #[test] + fn test_cross_contract_calls_for_different_learners_are_isolated() { + let env = Env::default(); + let (_admin, token_id, pt_id) = setup(&env); + let token_client = LearnTokenClient::new(&env, &token_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(quiz_id.clone()); + pt_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + // Alice scores 80 + pt_client.enroll(&alice, &course_id); + pt_client.submit_quiz_score(&alice, &course_id, &quiz_id, &80); + // Bob scores 90 + pt_client.enroll(&bob, &course_id); + pt_client.submit_quiz_score(&bob, &course_id, &quiz_id, &90); + + // Claims are cross-contract but learner-specific + token_client.claim_reward(&alice, &course_id, &quiz_id); + token_client.claim_reward(&bob, &course_id, &quiz_id); + + assert_eq!(token_client.balance(&alice), 8000); + assert_eq!(token_client.balance(&bob), 9000); + } +} From 5d0dc99132bafc7d5dcc832e1ade3034e44e2c71 Mon Sep 17 00:00:00 2001 From: markdavid000 Date: Mon, 31 Aug 2026 05:47:28 +0100 Subject: [PATCH 3/4] test: add gas benchmark tests for all contract functions (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures CPU instruction costs via env.budget() for each contract function: - ProgressTracker: create_course, enroll, complete_module, submit_quiz_score, get_progress - LearnToken: claim_reward, transfer, approve, transfer_from, mint, burn - CredentialNft: mint_credential, verify_credential, revoke_credential Includes: - Regression detection comparing batch vs individual claim costs - Full end-to-end flow benchmark (enroll→complete→claim→mint) - Results are repeatable with fresh environments per test - Ratios are relative (not absolute) for cross-host stability --- Cargo.toml | 4 + tests/benchmarks/gas_benchmarks.rs | 487 +++++++++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 tests/benchmarks/gas_benchmarks.rs diff --git a/Cargo.toml b/Cargo.toml index efa6d03..f8525bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,10 @@ path = "tests/integration/security_double_spending_tests.rs" name = "xcontract_call_tests" path = "tests/unit/xcontract_call_tests.rs" +[[test]] +name = "gas_benchmarks" +path = "tests/benchmarks/gas_benchmarks.rs" + [[test]] name = "xcontract_calls_bench" path = "tests/benchmarks/xcontract_calls.rs" diff --git a/tests/benchmarks/gas_benchmarks.rs b/tests/benchmarks/gas_benchmarks.rs new file mode 100644 index 0000000..0c15566 --- /dev/null +++ b/tests/benchmarks/gas_benchmarks.rs @@ -0,0 +1,487 @@ +//! Gas benchmark tests for ChainLearn contracts (#288). +//! +//! Measures CPU instruction costs via `env.budget()` for each major contract +//! function across all three contracts. Results are printed and compared +//! against thresholds to detect regressions. Benchmarks are repeatable: +//! each test sets up a fresh environment and resets the budget before +//! measuring. +//! +//! Soroban's host CPU-instruction numbers underestimate WASM costs, so these +//! are relative comparisons between operations, not absolute gas figures. + +use credential_nft::{CredentialNft, CredentialNftClient}; +use learn_token::LearnTokenClient; +use progress_tracker::{ProgressTracker, ProgressTrackerClient}; +use soroban_sdk::{ + testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec, +}; + +struct BenchEnv { + env: Env, + admin: Address, + token_client: LearnTokenClient<'static>, + credential_client: CredentialNftClient<'static>, + progress_client: ProgressTrackerClient<'static>, +} + +fn setup_bench() -> BenchEnv { + let env = Env::default(); + let admin = Address::generate(&env); + + let progress_contract_id = env.register_contract(None, ProgressTracker); + let progress_client = ProgressTrackerClient::new(&env, &progress_contract_id); + progress_client.initialize(&admin); + + let token_contract_id = env.register_contract(None, learn_token::LearnToken); + let token_client = LearnTokenClient::new(&env, &token_contract_id); + token_client.initialize( + &admin, + &SorobanString::from_str(&env, "CLearn"), + &SorobanString::from_str(&env, "CLRN"), + &7, + &progress_contract_id, + &1_000_000_000_000_000, + ); + + let credential_contract_id = env.register_contract(None, CredentialNft); + let credential_client = CredentialNftClient::new(&env, &credential_contract_id); + credential_client.initialize(&admin, &progress_contract_id); + + BenchEnv { + env, + admin, + token_client, + credential_client, + progress_client, + } +} + +// ── Progress Tracker Benchmarks ───────────────────────────────────────────── + +#[test] +fn bench_create_course() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "bench_course"); + let mut module_ids = Vec::new(env); + for i in 0..3 { + module_ids.push_back(Symbol::new(env, &format!("mod_{}", i))); + } + let mut quiz_ids = Vec::new(env); + for i in 0..2 { + quiz_ids.push_back(Symbol::new(env, &format!("quiz_{}", i))); + } + + env.budget().reset_default(); + bench.progress_client.create_course(&course_id, &3, &2, &module_ids, &quiz_ids); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench create_course: {cost} CPU insns"); + assert!(cost > 0, "create_course must have nonzero cost"); +} + +#[test] +fn bench_enroll() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "bench_course"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + let learner = Address::generate(env); + + env.budget().reset_default(); + bench.progress_client.enroll(&learner, &course_id); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench enroll: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_complete_module() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "bench_course"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + let learner = Address::generate(env); + bench.progress_client.enroll(&learner, &course_id); + + env.budget().reset_default(); + bench.progress_client.complete_module(&learner, &course_id, &Symbol::new(env, "mod_1")); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench complete_module: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_submit_quiz_score() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "bench_course"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + let learner = Address::generate(env); + bench.progress_client.enroll(&learner, &course_id); + + env.budget().reset_default(); + bench.progress_client.submit_quiz_score(&learner, &course_id, &Symbol::new(env, "quiz_1"), &80); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench submit_quiz_score: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_get_progress() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "bench_course"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + + let learner = Address::generate(env); + bench.progress_client.enroll(&learner, &course_id); + + env.budget().reset_default(); + let _progress = bench.progress_client.get_progress(&learner, &course_id); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench get_progress: {cost} CPU insns"); + assert!(cost > 0); +} + +// ── Learn Token Benchmarks ────────────────────────────────────────────────── + +fn setup_completed_course(bench: &BenchEnv, learner: &Address) -> (Symbol, Symbol) { + let course_id = Symbol::new(&bench.env, "bench_course"); + let quiz_id = Symbol::new(&bench.env, "quiz_1"); + let mut module_ids = Vec::new(&bench.env); + module_ids.push_back(Symbol::new(&bench.env, "mod_1")); + let mut quiz_ids = Vec::new(&bench.env); + quiz_ids.push_back(quiz_id.clone()); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + bench.progress_client.enroll(learner, &course_id); + bench.progress_client.complete_module(learner, &course_id, &Symbol::new(&bench.env, "mod_1")); + bench.progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); + (course_id, quiz_id) +} + +#[test] +fn bench_claim_reward() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let (course_id, quiz_id) = setup_completed_course(&bench, &learner); + + env.budget().reset_default(); + bench.token_client.claim_reward(&learner, &course_id, &quiz_id); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench claim_reward: {cost} CPU insns"); + assert!(cost > 0); + assert_eq!(bench.token_client.balance(&learner), 8000); +} + +#[test] +fn bench_transfer() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let from = Address::generate(env); + let to = Address::generate(env); + bench.token_client.mint(&bench.admin, &from, &10000); + + env.budget().reset_default(); + bench.token_client.transfer(&from, &to, &5000); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench transfer: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_approve_and_transfer_from() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let owner = Address::generate(env); + let spender = Address::generate(env); + let recipient = Address::generate(env); + bench.token_client.mint(&bench.admin, &owner, &10000); + + env.budget().reset_default(); + bench.token_client.approve(&owner, &spender, &5000, &999999); + let approve_cost = env.budget().cpu_instruction_cost(); + + env.budget().reset_default(); + bench.token_client.transfer_from(&spender, &owner, &recipient, &3000); + let transfer_from_cost = env.budget().cpu_instruction_cost(); + + println!("bench approve: {approve_cost} CPU insns"); + println!("bench transfer_from: {transfer_from_cost} CPU insns"); + assert!(approve_cost > 0); + assert!(transfer_from_cost > 0); +} + +#[test] +fn bench_mint() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let recipient = Address::generate(env); + + env.budget().reset_default(); + bench.token_client.mint(&bench.admin, &recipient, &10000); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench mint: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_burn() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let user = Address::generate(env); + bench.token_client.mint(&bench.admin, &user, &10000); + + env.budget().reset_default(); + bench.token_client.burn(&user, &5000); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench burn: {cost} CPU insns"); + assert!(cost > 0); + assert_eq!(bench.token_client.balance(&user), 5000); +} + +// ── Credential NFT Benchmarks ─────────────────────────────────────────────── + +fn setup_completed_course_for_credential( + bench: &BenchEnv, + learner: &Address, + score: u32, +) -> (Symbol, Symbol) { + let course_id = Symbol::new(&bench.env, "bench_course"); + let quiz_id = Symbol::new(&bench.env, "quiz_1"); + let mut module_ids = Vec::new(&bench.env); + module_ids.push_back(Symbol::new(&bench.env, "mod_1")); + let mut quiz_ids = Vec::new(&bench.env); + quiz_ids.push_back(quiz_id.clone()); + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + bench.progress_client.enroll(learner, &course_id); + bench.progress_client.complete_module(learner, &course_id, &Symbol::new(&bench.env, "mod_1")); + bench.progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &score); + (course_id, quiz_id) +} + +#[test] +fn bench_mint_credential() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let (course_id, _quiz_id) = setup_completed_course_for_credential(&bench, &learner, 85); + let metadata_uri = Symbol::new(env, "ipfs_Qm123"); + + env.budget().reset_default(); + bench.credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench mint_credential: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_verify_credential() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let (course_id, _quiz_id) = setup_completed_course_for_credential(&bench, &learner, 85); + let metadata_uri = Symbol::new(env, "ipfs_Qm123"); + let cred_id = bench.credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + + env.budget().reset_default(); + let _info = bench.credential_client.verify_credential(&cred_id); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench verify_credential: {cost} CPU insns"); + assert!(cost > 0); +} + +#[test] +fn bench_revoke_credential() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let (course_id, _quiz_id) = setup_completed_course_for_credential(&bench, &learner, 85); + let metadata_uri = Symbol::new(env, "ipfs_Qm123"); + let cred_id = bench.credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + + env.budget().reset_default(); + bench.credential_client.revoke_credential(&cred_id); + let cost = env.budget().cpu_instruction_cost(); + + println!("bench revoke_credential: {cost} CPU insns"); + assert!(cost > 0); +} + +// ── Regression Detection ──────────────────────────────────────────────────── +// +// Each benchmark records a baseline ratio. If the ratio between two +// operations changes by more than 50%, a regression is flagged. These +// ratios are intentionally relative (not absolute) so they stay stable +// across different host versions. + +#[test] +fn bench_regression_batch_vs_individual_claim() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + + let mut quiz_ids_src = Vec::new(env); + let n: usize = 5; + for i in 0..n { + quiz_ids_src.push_back(Symbol::new(env, &format!("quiz_{}", i))); + } + let course_id = Symbol::new(env, "bench_regress"); + bench.progress_client.create_course(&course_id, &(n as u32), &(n as u32), &module_ids, &quiz_ids_src); + bench.progress_client.enroll(&learner, &course_id); + bench.progress_client.complete_module(&learner, &course_id, &Symbol::new(env, "mod_1")); + for i in 0..n { + let qid = Symbol::new(env, &format!("quiz_{}", i)); + bench.progress_client.submit_quiz_score(&learner, &course_id, &qid, &80); + } + + // Individual claims + env.budget().reset_default(); + for i in 0..n { + let qid = Symbol::new(env, &format!("quiz_{}", i)); + bench.token_client.claim_reward(&learner, &course_id, &qid); + } + let individual_cost = env.budget().cpu_instruction_cost(); + + // Reset state for batch test: use a new learner and course + let bench2 = setup_bench(); + let env2 = &bench2.env; + env2.mock_all_auths(); + let learner2 = Address::generate(env2); + let mut module_ids2 = Vec::new(env2); + module_ids2.push_back(Symbol::new(env2, "mod_1")); + let mut quiz_ids_src2 = Vec::new(env2); + for i in 0..n { + quiz_ids_src2.push_back(Symbol::new(env2, &format!("quiz_{}", i))); + } + let course_id2 = Symbol::new(env2, "bench_regress2"); + bench2.progress_client.create_course(&course_id2, &(n as u32), &(n as u32), &module_ids2, &quiz_ids_src2); + bench2.progress_client.enroll(&learner2, &course_id2); + bench2.progress_client.complete_module(&learner2, &course_id2, &Symbol::new(env2, "mod_1")); + for i in 0..n { + let qid = Symbol::new(env2, &format!("quiz_{}", i)); + bench2.progress_client.submit_quiz_score(&learner2, &course_id2, &qid, &80); + } + + env2.budget().reset_default(); + let quiz_ids: Vec = (0..n) + .map(|i| Symbol::new(env2, &format!("quiz_{}", i))) + .collect(); + let claimed = bench2.token_client.batch_claim_reward(&learner2, &course_id2, &quiz_ids); + let batch_cost = env2.budget().cpu_instruction_cost(); + assert_eq!(claimed.len(), n as u32); + + let ratio = batch_cost as f64 / individual_cost as f64; + + println!( + "regression bench: {n} claims — individual = {individual_cost}, batch = {batch_cost} (ratio: {ratio:.3})" + ); + + // Batch must be cheaper than individual (ratio < 1.0) + assert!( + ratio < 1.0, + "batch_claim_reward ({batch_cost}) should cost less than {n} individual claims ({individual_cost}), ratio: {ratio:.3}" + ); + + // Ratio should not regress beyond 0.9 (90% of individual) — if batch + // approaches individual cost, an optimization was lost. + assert!( + ratio < 0.9, + "batch claim ratio {ratio:.3} exceeds 0.9 regression threshold — possible optimization regression" + ); +} + +// ── End-to-end flow benchmark ─────────────────────────────────────────────── + +#[test] +fn bench_e2e_enroll_complete_claim_mint() { + let bench = setup_bench(); + let env = &bench.env; + env.mock_all_auths(); + + let learner = Address::generate(env); + let course_id = Symbol::new(env, "e2e_course"); + let quiz_id = Symbol::new(env, "quiz_1"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(quiz_id.clone()); + + env.budget().reset_default(); + + bench.progress_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + bench.progress_client.enroll(&learner, &course_id); + bench.progress_client.complete_module(&learner, &course_id, &Symbol::new(env, "mod_1")); + bench.progress_client.submit_quiz_score(&learner, &course_id, &quiz_id, &85); + bench.token_client.claim_reward(&learner, &course_id, &quiz_id); + + let metadata_uri = Symbol::new(env, "ipfs_Qm123"); + bench.credential_client.mint_credential(&learner, &course_id, &85, &metadata_uri); + + let total_cost = env.budget().cpu_instruction_cost(); + + println!("bench e2e enroll→complete→claim→mint: {total_cost} CPU insns"); + assert!(total_cost > 0); + assert_eq!(bench.token_client.balance(&learner), 8500); + assert!(bench.credential_client.is_credential_valid(&1)); +} From a5c6fa8accdbe18c02f6fc19798bc2f114646ebb Mon Sep 17 00:00:00 2001 From: markdavid000 Date: Mon, 31 Aug 2026 05:50:15 +0100 Subject: [PATCH 4/4] test: add unit tests for event emissions (#286) Verifies all events are emitted with correct topics and data: ProgressTracker events: - course_created, enrolled (with timestamp), module_completed, quiz_submitted, credential_eligible, quiz_retaken, course_archived LearnToken events: - mint, transfer (indexed topics), burn, approve, reward_claimed, role_granted, role_revoked, max_supply_updated, vesting_created CredentialNft events: - credential_minted (with metadata_uri), credential_revoked (indexed, with audit details) All event topics and data payloads are verified for correctness. --- Cargo.toml | 4 + tests/unit/event_emission_tests.rs | 682 +++++++++++++++++++++++++++++ 2 files changed, 686 insertions(+) create mode 100644 tests/unit/event_emission_tests.rs diff --git a/Cargo.toml b/Cargo.toml index f8525bc..bbf17d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,10 @@ path = "tests/integration/security_double_spending_tests.rs" name = "xcontract_call_tests" path = "tests/unit/xcontract_call_tests.rs" +[[test]] +name = "event_emission_tests" +path = "tests/unit/event_emission_tests.rs" + [[test]] name = "gas_benchmarks" path = "tests/benchmarks/gas_benchmarks.rs" diff --git a/tests/unit/event_emission_tests.rs b/tests/unit/event_emission_tests.rs new file mode 100644 index 0000000..ce22538 --- /dev/null +++ b/tests/unit/event_emission_tests.rs @@ -0,0 +1,682 @@ +//! Unit tests for event emissions across all ChainLearn contracts (#286). +//! +//! Verifies that every event-emitting function produces events with the +//! correct topics (indexed fields) and data payload. Events are critical +//! for indexers — these tests ensure they are emitted correctly and are +//! queryable via topic filters. + +use credential_nft::{CredentialNft, CredentialNftClient}; +use learn_token::{AdminRole, LearnTokenClient}; +use progress_tracker::{ProgressTracker, ProgressTrackerClient}; +use soroban_sdk::{ + testutils::{Address as _, Events as _}, + Address, Env, IntoVal, String as SorobanString, Symbol, Vec, +}; + +// ── Progress Tracker Events ───────────────────────────────────────────────── + +#[cfg(test)] +mod progress_tracker_events { + use super::*; + + fn setup(env: &Env) -> (Address, Address) { + let admin = Address::generate(env); + let contract_id = env.register_contract(None, ProgressTracker); + let client = ProgressTrackerClient::new(env, &contract_id); + client.initialize(&admin); + (admin, contract_id) + } + + fn create_course(env: &Env, client: &ProgressTrackerClient) -> Symbol { + let course_id = Symbol::new(env, "event_course"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + module_ids.push_back(Symbol::new(env, "mod_2")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + quiz_ids.push_back(Symbol::new(env, "quiz_2")); + client.create_course(&course_id, &2, &2, &module_ids, &quiz_ids); + course_id + } + + #[test] + fn test_create_course_emits_course_created_event() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "new_course"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "m1")); + module_ids.push_back(Symbol::new(&env, "m2")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(Symbol::new(&env, "q1")); + + client.create_course(&course_id, &2, &1, &module_ids, &quiz_ids); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id.clone(), + (Symbol::new(&env, "course_created"),).into_val(&env), + (course_id.clone(), 2u32, 1u32, module_ids.clone()).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_enroll_emits_enrolled_event_with_timestamp() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + let learner = Address::generate(&env); + + env.ledger().with_mut(|l| l.timestamp = 99999); + client.enroll(&learner, &course_id); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (soroban_sdk::symbol_short!("enrolled"),).into_val(&env), + (learner, course_id, 99999u64).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_complete_module_emits_module_completed_event() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + let learner = Address::generate(&env); + client.enroll(&learner, &course_id); + client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_1")); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + let (_, topics, data) = last; + + let topics_vec: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics_vec.len(), 1); + let event_name: Symbol = topics_vec.get(0).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "module_completed")); + + let data_vec: soroban_sdk::Vec = data.clone(); + assert_eq!(data_vec.len(), 4); + let data_learner: Address = data_vec.get(0).unwrap().into_val(&env); + let data_course: Symbol = data_vec.get(1).unwrap().into_val(&env); + let data_module: Symbol = data_vec.get(2).unwrap().into_val(&env); + assert_eq!(data_learner, learner); + assert_eq!(data_course, course_id); + assert_eq!(data_module, Symbol::new(&env, "mod_1")); + } + + #[test] + fn test_submit_quiz_score_emits_quiz_submitted_event() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + let learner = Address::generate(&env); + client.enroll(&learner, &course_id); + client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1"), &85); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + let (_, topics, data) = last; + + let topics_vec: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics_vec.len(), 1); + let event_name: Symbol = topics_vec.get(0).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "quiz_submitted")); + + let data_vec: soroban_sdk::Vec = data.clone(); + assert_eq!(data_vec.len(), 4); + let data_score: u32 = data_vec.get(3).unwrap().into_val(&env); + assert_eq!(data_score, 85); + } + + #[test] + fn test_credential_eligible_event_emitted_on_eligibility_flip() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + let learner = Address::generate(&env); + client.enroll(&learner, &course_id); + + // Complete all requirements — the last event must be credential_eligible + client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_1")); + client.complete_module(&learner, &course_id, &Symbol::new(&env, "mod_2")); + client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_1"), &80); + client.submit_quiz_score(&learner, &course_id, &Symbol::new(&env, "quiz_2"), &70); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "credential_eligible"),).into_val(&env), + (learner, course_id).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_retake_quiz_emits_quiz_retaken_event() { + let env = Env::default(); + let (_admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + let learner = Address::generate(&env); + let quiz_id = Symbol::new(&env, "quiz_1"); + client.enroll(&learner, &course_id); + client.submit_quiz_score(&learner, &course_id, &quiz_id, &40); + client.retake_quiz(&learner, &course_id, &quiz_id, &90); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "quiz_retaken"),).into_val(&env), + (learner, course_id, quiz_id, 40u32, 90u32).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_archive_course_emits_course_archived_event() { + let env = Env::default(); + let (admin, contract_id) = setup(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_course(&env, &client); + client.archive_course(&course_id); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "course_archived"),).into_val(&env), + (course_id,).into_val(&env), + ) + ] + ); + } +} + +// ── Learn Token Events ────────────────────────────────────────────────────── + +#[cfg(test)] +mod learn_token_events { + use super::*; + + fn setup(env: &Env) -> (Address, Address, Address) { + let admin = Address::generate(env); + + let pt_contract_id = env.register_contract(None, ProgressTracker); + let pt_client = ProgressTrackerClient::new(env, &pt_contract_id); + pt_client.initialize(&admin); + + let contract_id = env.register_contract(None, learn_token::LearnToken); + let client = LearnTokenClient::new(env, &contract_id); + client.initialize( + &admin, + &SorobanString::from_str(env, "CLearn"), + &SorobanString::from_str(env, "CLRN"), + &7, + &pt_contract_id, + &1_000_000_000_000_000, + ); + + (admin, contract_id, pt_contract_id) + } + + #[test] + fn test_mint_emits_mint_event() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let recipient = Address::generate(&env); + client.mint(&admin, &recipient, &5000); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "mint"), recipient.clone()).into_val(&env), + (5000i128,).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_transfer_emits_transfer_event() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let from = Address::generate(&env); + let to = Address::generate(&env); + client.mint(&admin, &from, &10000); + client.transfer(&from, &to, &3000); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "transfer"), from.clone(), to.clone()).into_val(&env), + (3000i128,).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_transfer_emits_indexed_topics() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let from = Address::generate(&env); + let to = Address::generate(&env); + client.mint(&admin, &from, &10000); + client.transfer(&from, &to, &3000); + + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics.len(), 3); + let event_name: Symbol = topics.get(0).unwrap().into_val(&env); + let from_topic: Address = topics.get(1).unwrap().into_val(&env); + let to_topic: Address = topics.get(2).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "transfer")); + assert_eq!(from_topic, from); + assert_eq!(to_topic, to); + } + + #[test] + fn test_burn_emits_burn_event() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let user = Address::generate(&env); + client.mint(&admin, &user, &10000); + client.burn(&user, &2000); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "burn"), user.clone()).into_val(&env), + (2000i128,).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_approve_emits_approve_event() { + let env = Env::default(); + let (_admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let spender = Address::generate(&env); + client.approve(&owner, &spender, &5000, &999999); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "approve"), owner.clone(), spender.clone()).into_val(&env), + (5000i128, 999999u32).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_claim_reward_emits_reward_event() { + let env = Env::default(); + let (_admin, contract_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_id); + env.mock_all_auths(); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + let mut module_ids = Vec::new(&env); + module_ids.push_back(Symbol::new(&env, "mod_1")); + let mut quiz_ids = Vec::new(&env); + quiz_ids.push_back(quiz_id.clone()); + pt_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids); + pt_client.enroll(&learner, &course_id); + pt_client.submit_quiz_score(&learner, &course_id, &quiz_id, &80); + + client.claim_reward(&learner, &course_id, &quiz_id); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "reward"), learner.clone(), course_id.clone()).into_val(&env), + (quiz_id, 80u32, 8000i128).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_role_granted_emits_event() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let new_admin = Address::generate(&env); + client.grant_role(&admin, &new_admin, &AdminRole::Minter); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "role_granted"), new_admin.clone()).into_val(&env), + (Symbol::new(&env, "Minter"),).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_role_revoked_emits_event() { + let env = Env::default(); + let (admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let user = Address::generate(&env); + client.grant_role(&admin, &user, &AdminRole::Minter); + client.revoke_role(&admin, &user, &AdminRole::Minter); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "role_revoked"), user.clone()).into_val(&env), + (Symbol::new(&env, "Minter"),).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_max_supply_updated_emits_event() { + let env = Env::default(); + let (_admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + client.set_max_supply(&8000); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "max_supply_updated"),).into_val(&env), + (1_000_000_000_000_000i128, 8000i128).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_vesting_created_emits_event() { + let env = Env::default(); + let (_admin, contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, &contract_id); + env.mock_all_auths(); + + let beneficiary = Address::generate(&env); + client.create_vesting(&beneficiary, &10_000, &100, &1_000); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id, + (Symbol::new(&env, "vesting_created"), beneficiary.clone()).into_val(&env), + (10_000i128, 100u64, 1_000u64).into_val(&env), + ) + ] + ); + } +} + +// ── Credential NFT Events ─────────────────────────────────────────────────── + +#[cfg(test)] +mod credential_nft_events { + use super::*; + + fn setup(env: &Env) -> (Address, Address, Address) { + let admin = Address::generate(env); + + let tracker_id = env.register_contract(None, ProgressTracker); + let tracker_client = ProgressTrackerClient::new(env, &tracker_id); + tracker_client.initialize(&admin); + + let contract_id = env.register_contract(None, CredentialNft); + let client = CredentialNftClient::new(env, &contract_id); + client.initialize(&admin, &tracker_id); + + (admin, contract_id, tracker_id) + } + + fn complete_course( + env: &Env, + tracker_id: &Address, + learner: &Address, + course_id: &Symbol, + score: u32, + ) { + let tracker_client = ProgressTrackerClient::new(env, tracker_id); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + tracker_client.create_course(course_id, &1, &1, &module_ids, &quiz_ids); + tracker_client.enroll(learner, course_id); + tracker_client.complete_module(learner, course_id, &Symbol::new(&env, "mod_1")); + tracker_client.submit_quiz_score(learner, course_id, &Symbol::new(&env, "quiz_1"), &score); + } + + #[test] + fn test_mint_credential_emits_credential_minted_event() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup(&env); + let client = CredentialNftClient::new(&env, &contract_id); + env.mock_all_auths(); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let uri = Symbol::new(&env, "ipfs_Qm123"); + complete_course(&env, &tracker_id, &learner, &course_id, 85); + + let cred_id = client.mint_credential(&learner, &course_id, &85, &uri); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id.clone(), + (Symbol::new(&env, "credential_minted"),).into_val(&env), + (learner, course_id, cred_id, 85u32, uri).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_revoke_credential_emits_credential_revoked_event() { + let env = Env::default(); + let (admin, contract_id, tracker_id) = setup(&env); + let client = CredentialNftClient::new(&env, &contract_id); + env.mock_all_auths(); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let uri = Symbol::new(&env, "ipfs_meta"); + complete_course(&env, &tracker_id, &learner, &course_id, 80); + + let cred_id = client.mint_credential(&learner, &course_id, &80, &uri); + client.revoke_credential(&cred_id); + + let all = env.events().all(); + let last = all.last().expect("no events emitted"); + assert_eq!( + soroban_sdk::vec![&env, last], + soroban_sdk::vec![ + &env, + ( + contract_id.clone(), + (Symbol::new(&env, "credential_revoked"),).into_val(&env), + (learner, course_id, cred_id, admin).into_val(&env), + ) + ] + ); + } + + #[test] + fn test_credential_revoked_event_is_indexed() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup(&env); + let client = CredentialNftClient::new(&env, &contract_id); + env.mock_all_auths(); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let uri = Symbol::new(&env, "ipfs_meta"); + complete_course(&env, &tracker_id, &learner, &course_id, 80); + + let cred_id = client.mint_credential(&learner, &course_id, &80, &uri); + client.revoke_credential(&cred_id); + + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics_vec: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics_vec.len(), 1); + let event_name: Symbol = topics_vec.get(0).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "credential_revoked")); + } + + #[test] + fn test_credential_minted_event_data_contains_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup(&env); + let client = CredentialNftClient::new(&env, &contract_id); + env.mock_all_auths(); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let uri = Symbol::new(&env, "ipfs_QmUniqueURI"); + complete_course(&env, &tracker_id, &learner, &course_id, 90); + + let cred_id = client.mint_credential(&learner, &course_id, &90, &uri); + + let all = env.events().all(); + let (_, _, data) = all.last().expect("no events emitted"); + let data_vec: soroban_sdk::Vec = data.clone(); + assert_eq!(data_vec.len(), 5); + let data_uri: Symbol = data_vec.get(4).unwrap().into_val(&env); + assert_eq!(data_uri, uri); + + let data_cred_id: u64 = data_vec.get(2).unwrap().into_val(&env); + assert_eq!(data_cred_id, cred_id); + } +}