From d0894ae42f35ca57f365707d3cd82aa29494cc78 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 20:25:14 +0100 Subject: [PATCH 1/4] feat(token): Add test for token vesting (#295) --- tests/unit/token_tests.rs | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index f7f055c..62a8f2b 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -877,4 +877,109 @@ mod token_unit_tests { let executed = client.get_proposal(&proposal_id).unwrap(); assert!(executed.executed); } + + #[test] + fn test_vesting_schedule_cliff_linear_vesting_and_claiming() { + let env = Env::default(); + let (admin, contract_id, _) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + let beneficiary = Address::generate(&env); + + env.mock_all_auths(); + + let total_amount: i128 = 10_000; + let cliff_timestamp: u64 = 100; + let duration_seconds: u64 = 1_000; + + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + client.create_vesting(&beneficiary, &total_amount, &cliff_timestamp, &duration_seconds); + + let schedule = client.get_vesting_schedule(&beneficiary).expect("schedule should exist"); + assert_eq!(schedule.total_amount, 10_000); + assert_eq!(schedule.cliff_timestamp, 100); + assert_eq!(schedule.duration_seconds, 1_000); + assert!(!schedule.exhausted); + assert_eq!(client.get_vesting_claimed(&beneficiary), 0); + + // Linear vesting halfway through cliff + 500s (50% vested) + env.ledger().with_mut(|l| { + l.timestamp = cliff_timestamp + 500; + }); + + client.claim_vested(&beneficiary); + assert_eq!(client.balance(&beneficiary), 5_000); + assert_eq!(client.get_vesting_claimed(&beneficiary), 5_000); + assert_eq!(client.total_supply(), 5_000); + + let mid_schedule = client.get_vesting_schedule(&beneficiary).unwrap(); + assert!(!mid_schedule.exhausted); + + // Complete linear vesting cliff + 1000s (100% vested) + env.ledger().with_mut(|l| { + l.timestamp = cliff_timestamp + 1_000; + }); + + client.claim_vested(&beneficiary); + assert_eq!(client.balance(&beneficiary), 10_000); + assert_eq!(client.get_vesting_claimed(&beneficiary), 10_000); + assert_eq!(client.total_supply(), 10_000); + + let final_schedule = client.get_vesting_schedule(&beneficiary).unwrap(); + assert!(final_schedule.exhausted); + } + + #[test] + #[should_panic(expected = "cliff not reached")] + fn test_vesting_cliff_enforced() { + let env = Env::default(); + let (admin, contract_id, _) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + let beneficiary = Address::generate(&env); + + env.mock_all_auths(); + + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + client.create_vesting(&beneficiary, &10_000, &100, &1_000); + + // Advance to timestamp 50 (cliff is 100) + env.ledger().with_mut(|l| { + l.timestamp = 50; + }); + + client.claim_vested(&beneficiary); + } + + #[test] + #[should_panic(expected = "vesting schedule fully claimed")] + fn test_vesting_claiming_after_exhausted_panics() { + let env = Env::default(); + let (admin, contract_id, _) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + let beneficiary = Address::generate(&env); + + env.mock_all_auths(); + + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + client.create_vesting(&beneficiary, &10_000, &100, &1_000); + + env.ledger().with_mut(|l| { + l.timestamp = 1100; + }); + + client.claim_vested(&beneficiary); + assert_eq!(client.balance(&beneficiary), 10_000); + + // Attempting to claim again after schedule is exhausted must panic + client.claim_vested(&beneficiary); + } } + From 7603ed2df67799f8c0fee8ed80346797087372b9 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 20:25:30 +0100 Subject: [PATCH 2/4] feat(auth): Add test for multi-admin support (#294) --- tests/integration/admin_role_flow.rs | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/integration/admin_role_flow.rs b/tests/integration/admin_role_flow.rs index 7016457..cfdd685 100644 --- a/tests/integration/admin_role_flow.rs +++ b/tests/integration/admin_role_flow.rs @@ -166,3 +166,89 @@ fn test_multi_admin_management_and_multisig_ops() { assert_eq!(admins.len(), 2); assert!(!token_client.has_role(&minter_admin, &AdminRole::Minter)); } + +#[test] +fn test_multi_admin_role_enforcement_revocation_and_events() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let primary_admin = &setup.admin; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + + let admin_minter = Address::generate(env); + let admin_pauser = Address::generate(env); + let recipient = Address::generate(env); + + // 1. Add multiple admins with distinct roles + token_client.add_admin( + primary_admin, + &learn_token::AdminInfo { + address: admin_minter.clone(), + role: AdminRole::Minter, + }, + ); + + token_client.add_admin( + primary_admin, + &learn_token::AdminInfo { + address: admin_pauser.clone(), + role: AdminRole::Pauser, + }, + ); + + let admins = token_client.get_admins(); + assert_eq!(admins.len(), 3); + + // Verify role granted events were emitted + let events = env.events().all(); + assert!(events.len() >= 2); + + // 2. Enforce roles + assert!(token_client.has_role(&admin_minter, &AdminRole::Minter)); + assert!(!token_client.has_role(&admin_minter, &AdminRole::Pauser)); + assert!(token_client.has_role(&admin_pauser, &AdminRole::Pauser)); + assert!(!token_client.has_role(&admin_pauser, &AdminRole::Minter)); + + // admin_minter can mint + token_client.mint(&admin_minter, &recipient, &500); + assert_eq!(token_client.balance(&recipient), 500); + + // admin_minter cannot pause + assert!(token_client.try_pause(&admin_minter).is_err()); + + // admin_pauser can pause + token_client.pause(&admin_pauser); + assert!(token_client.is_paused()); + token_client.unpause(&admin_pauser); + + // admin_pauser cannot mint + assert!(token_client.try_mint(&admin_pauser, &recipient, &500).is_err()); + + // 3. Revocation + token_client.remove_admin( + primary_admin, + &learn_token::AdminInfo { + address: admin_minter.clone(), + role: AdminRole::Minter, + }, + ); + + token_client.remove_admin( + primary_admin, + &learn_token::AdminInfo { + address: admin_pauser.clone(), + role: AdminRole::Pauser, + }, + ); + + // Verify roles are revoked and actions fail + assert!(!token_client.has_role(&admin_minter, &AdminRole::Minter)); + assert!(!token_client.has_role(&admin_pauser, &AdminRole::Pauser)); + assert!(token_client.try_mint(&admin_minter, &recipient, &100).is_err()); + assert!(token_client.try_pause(&admin_pauser).is_err()); + + let final_admins = token_client.get_admins(); + assert_eq!(final_admins.len(), 1); +} + From 5b455e35063c487cee3f34a6ddb3991d7747221f Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 20:25:45 +0100 Subject: [PATCH 3/4] feat(upgrade): Add test for contract upgrade mechanism (#293) --- tests/integration/upgrade_tests.rs | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/integration/upgrade_tests.rs b/tests/integration/upgrade_tests.rs index f196fcc..ca25192 100644 --- a/tests/integration/upgrade_tests.rs +++ b/tests/integration/upgrade_tests.rs @@ -49,3 +49,58 @@ fn test_contract_upgrade() { assert_eq!(client.balance(&user), 100); } + +#[test] +fn test_contract_upgrade_preserves_state_and_updates_version() { + let env = Env::default(); + let admin = Address::generate(&env); + let pt_contract_id = env.register_contract(None, ProgressTracker); + let token_id = env.register_contract(None, LearnToken); + let client = LearnTokenClient::new(&env, &token_id); + + client.initialize( + &admin, + &SorobanString::from_str(&env, "ChainLearn"), + &SorobanString::from_str(&env, "CLRN"), + &7, + &pt_contract_id, + &1_000_000, + ); + + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + client.mint(&admin, &alice, &500); + client.mint(&admin, &bob, &300); + + assert_eq!(client.balance(&alice), 500); + assert_eq!(client.balance(&bob), 300); + assert_eq!(client.total_supply(), 800); + assert_eq!(client.upgrade_version(), 0); + assert_eq!(client.wasm_hash(), None); + + let new_wasm_hash = BytesN::from_array(&env, &[2u8; 32]); + client.upgrade(&new_wasm_hash); + + assert_eq!(client.upgrade_version(), 1); + assert_eq!(client.wasm_hash(), Some(new_wasm_hash)); + + // Verify state preserved after upgrade + assert_eq!(client.balance(&alice), 500); + assert_eq!(client.balance(&bob), 300); + assert_eq!(client.total_supply(), 800); + assert_eq!(client.admin(), admin); + assert_eq!(client.max_supply(), 1_000_000); + + // Verify contract functions operate correctly post-upgrade + client.transfer(&alice, &bob, &200); + assert_eq!(client.balance(&alice), 300); + assert_eq!(client.balance(&bob), 500); + + client.mint(&admin, &alice, &100); + assert_eq!(client.balance(&alice), 400); + assert_eq!(client.total_supply(), 900); +} + From fdbbd2616ed6e7f45027b10c2eda6bc9f63cb4dd Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Sun, 30 Aug 2026 20:25:58 +0100 Subject: [PATCH 4/4] feat(security): Add security test for reentrancy (#292) --- .../integration/security_reentrancy_tests.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/integration/security_reentrancy_tests.rs b/tests/integration/security_reentrancy_tests.rs index 2c64e0b..fa240f8 100644 --- a/tests/integration/security_reentrancy_tests.rs +++ b/tests/integration/security_reentrancy_tests.rs @@ -16,6 +16,12 @@ impl MaliciousContract { // Attempt an unauthorized call during contract execution client.transfer(&Address::generate(&env), &Address::generate(&env), &1); } + + pub fn attack_mint(env: Env, token_id: Address, recipient: Address) { + let client = LearnTokenClient::new(&env, &token_id); + // Attempt reentrant mint call during state change execution + client.mint(&Address::generate(&env), &recipient, &5000); + } } #[test] @@ -46,3 +52,50 @@ fn test_reentrancy_during_transfer() { // The environment naturally protects against state corruption, often panicking if a re-entrant lock is triggered. malicious_client.attack(&token_id); } + +#[test] +fn test_reentrancy_prevented_state_consistent_and_no_funds_lost() { + let env = Env::default(); + let admin = Address::generate(&env); + let pt_contract_id = env.register_contract(None, ProgressTracker); + let token_id = env.register_contract(None, LearnToken); + let client = LearnTokenClient::new(&env, &token_id); + + client.initialize( + &admin, + &SorobanString::from_str(&env, "ChainLearn"), + &SorobanString::from_str(&env, "CLRN"), + &7, + &pt_contract_id, + &1_000_000, + ); + + let victim = Address::generate(&env); + let malicious_id = env.register_contract(None, MaliciousContract); + let malicious_client = MaliciousContractClient::new(&env, &malicious_id); + + env.mock_all_auths(); + + client.mint(&admin, &victim, &1_000); + client.mint(&admin, &malicious_id, &500); + + let victim_balance_before = client.balance(&victim); + let malicious_balance_before = client.balance(&malicious_id); + let supply_before = client.total_supply(); + + assert_eq!(victim_balance_before, 1_000); + assert_eq!(malicious_balance_before, 500); + assert_eq!(supply_before, 1_500); + + // Reentrant attack must fail / revert + let attack_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + malicious_client.attack_mint(&token_id, &malicious_id); + })); + assert!(attack_result.is_err(), "reentrant call must fail"); + + // State remains consistent after failed reentrancy attack (no funds lost) + assert_eq!(client.balance(&victim), victim_balance_before); + assert_eq!(client.balance(&malicious_id), malicious_balance_before); + assert_eq!(client.total_supply(), supply_before); +} +