From 1697c8ccfa178f0f0a6331cebdf81fd317699c5f Mon Sep 17 00:00:00 2001 From: blurbeast Date: Sun, 30 Aug 2026 10:47:03 +0100 Subject: [PATCH] feat: add governance to set_max_supply, max_supply_updated event, metadata_uri validation, and vitest configs - Validate that metadata_uri in credential-nft mint is non-empty, >= 8 chars, and well-formed - Add max_supply_updated event to learn-token set_max_supply - Implement governance safeguard (max 2x increase limit) on set_max_supply - Add vitest.config.ts for chainlearn-api and chainlearn-indexer - Fix duplicate function definitions in storage and compiler warnings Closes #181, Closes #182, Closes #184, Closes #186 --- Cargo.lock | 1 + chainlearn-api/vitest.config.ts | 20 +++++ chainlearn-indexer/vitest.config.ts | 20 +++++ contracts/credential-nft/Cargo.toml | 1 + contracts/credential-nft/src/lib.rs | 72 ++++++++++++++++ contracts/credential-nft/src/mint.rs | 34 +++++++- contracts/learn-token/src/events.rs | 9 ++ contracts/learn-token/src/lib.rs | 16 +++- contracts/learn-token/src/storage.rs | 15 +--- tests/integration/full_flow.rs | 2 +- tests/unit/credential_tests.rs | 70 +++++++++++++++ tests/unit/token_tests.rs | 122 +++++++++++++++++++++++++-- 12 files changed, 357 insertions(+), 25 deletions(-) create mode 100644 chainlearn-api/vitest.config.ts create mode 100644 chainlearn-indexer/vitest.config.ts diff --git a/Cargo.lock b/Cargo.lock index 87e8e76..414d564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,7 @@ version = "0.1.0" dependencies = [ "chainlearn-shared", "progress-tracker", + "soroban-env-common", "soroban-sdk", ] diff --git a/chainlearn-api/vitest.config.ts b/chainlearn-api/vitest.config.ts new file mode 100644 index 0000000..0b61f2f --- /dev/null +++ b/chainlearn-api/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '**/*.{test,spec}.{ts,js}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.d.ts', + '**/*.config.*', + '**/coverage/**', + ], + }, + }, +}); diff --git a/chainlearn-indexer/vitest.config.ts b/chainlearn-indexer/vitest.config.ts new file mode 100644 index 0000000..0b61f2f --- /dev/null +++ b/chainlearn-indexer/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '**/*.{test,spec}.{ts,js}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.d.ts', + '**/*.config.*', + '**/coverage/**', + ], + }, + }, +}); diff --git a/contracts/credential-nft/Cargo.toml b/contracts/credential-nft/Cargo.toml index 15da27e..30adde3 100644 --- a/contracts/credential-nft/Cargo.toml +++ b/contracts/credential-nft/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "lib"] [dependencies] soroban-sdk = { workspace = true } +soroban-env-common = { version = "21.0.0", default-features = false } chainlearn-shared = { path = "../../packages/shared" } [dev-dependencies] diff --git a/contracts/credential-nft/src/lib.rs b/contracts/credential-nft/src/lib.rs index 70e996e..5a192dc 100644 --- a/contracts/credential-nft/src/lib.rs +++ b/contracts/credential-nft/src/lib.rs @@ -1198,4 +1198,76 @@ mod tests { let info = client.verify_credential(&cred_id); assert_eq!(info.metadata_uri, cert_uri); } + + // ── Metadata URI Validation Tests ─────────────────────────────────────────── + + #[test] + #[should_panic(expected = "metadata_uri cannot be empty")] + fn test_mint_rejects_empty_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course, 85); + + let empty_uri = Symbol::new(&env, ""); + client.mint_credential(&learner, &course, &85, &empty_uri); + } + + #[test] + #[should_panic(expected = "metadata_uri too short: minimum length is 8")] + fn test_mint_rejects_too_short_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course, 85); + + let short_uri = Symbol::new(&env, "ipfs_1"); + client.mint_credential(&learner, &course, &85, &short_uri); + } + + #[test] + #[should_panic(expected = "metadata_uri is malformed: must start with a valid URI scheme")] + fn test_mint_rejects_malformed_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course, 85); + + let invalid_uri = Symbol::new(&env, "ftp_metadata_hash"); + client.mint_credential(&learner, &course, &85, &invalid_uri); + } + + #[test] + fn test_mint_accepts_valid_schemes() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course, 85); + + let uri = Symbol::new(&env, "ipfs_hash12345"); + let id = client.mint_credential(&learner, &course, &85, &uri); + assert_eq!(id, 1); + let info = client.verify_credential(&id); + assert_eq!(info.metadata_uri, uri); + } } diff --git a/contracts/credential-nft/src/mint.rs b/contracts/credential-nft/src/mint.rs index b28e18c..3dd6511 100644 --- a/contracts/credential-nft/src/mint.rs +++ b/contracts/credential-nft/src/mint.rs @@ -1,9 +1,37 @@ use chainlearn_shared::MIN_CREDENTIAL_SCORE; -use soroban_sdk::{Address, Env, Symbol}; +use soroban_env_common::SymbolStr; +use soroban_sdk::{Address, Env, Symbol, TryFromVal}; use crate::metadata::{CredentialDataKey, CredentialInfo}; use crate::ProgressTrackerClient; +/// Validate that `metadata_uri` is non-empty, meets the minimum length (>= 8 characters), +/// and starts with a recognized URI scheme (http, https, ipfs, or cert). +pub fn validate_metadata_uri(env: &Env, metadata_uri: &Symbol) { + let sstr = match SymbolStr::try_from_val(env, &metadata_uri.to_symbol_val()) { + Ok(s) => s, + Err(_) => panic!("metadata_uri is malformed"), + }; + let uri: &str = sstr.as_ref(); + if uri.is_empty() { + panic!("metadata_uri cannot be empty"); + } + if uri.len() < 8 { + panic!("metadata_uri too short: minimum length is 8"); + } + let has_valid_scheme = uri.starts_with("ipfs_") + || uri.starts_with("ipfs://") + || uri.starts_with("http_") + || uri.starts_with("http://") + || uri.starts_with("https_") + || uri.starts_with("https://") + || uri.starts_with("cert_") + || uri.starts_with("cert://"); + if !has_valid_scheme { + panic!("metadata_uri is malformed: must start with a valid URI scheme"); + } +} + /// Mint a new credential NFT for a learner. /// /// The credential is only minted if the learner's score meets the minimum @@ -22,6 +50,7 @@ use crate::ProgressTrackerClient; /// The unique credential ID. /// /// # Panics +/// * If `metadata_uri` is empty, too short (< 8 chars), or malformed /// * If `course_id` does not correspond to a known course /// * If score is below the minimum threshold /// * If the learner already has a credential for this course @@ -35,6 +64,9 @@ pub fn mint_credential( score: u32, metadata_uri: &Symbol, ) -> u64 { + // Metadata URI gate: must be non-empty, >= 8 chars, with a valid scheme + validate_metadata_uri(env, metadata_uri); + // Score gate: only mint if score >= 50 if score < MIN_CREDENTIAL_SCORE { panic!( diff --git a/contracts/learn-token/src/events.rs b/contracts/learn-token/src/events.rs index 088fbb3..3560d80 100644 --- a/contracts/learn-token/src/events.rs +++ b/contracts/learn-token/src/events.rs @@ -298,3 +298,12 @@ pub fn proposal_executed( let topics = (Symbol::new(env, "proposal_executed"),); env.events().publish(topics, (proposal_id, winning_choice, winning_votes)); } + +/// Emitted when the maximum supply cap is updated. +/// +/// Topics: ["max_supply_updated"] — admin config event. +/// Data: (old_max_supply, new_max_supply) +pub fn max_supply_updated(env: &Env, old_max_supply: i128, new_max_supply: i128) { + let topics = (Symbol::new(env, "max_supply_updated"),); + env.events().publish(topics, (old_max_supply, new_max_supply)); +} diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index cfe178b..8306e81 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -705,7 +705,7 @@ impl LearnToken { successful.push_back(quiz_id); } - if successful.len() > 0 { + if !successful.is_empty() { storage::set_balance(&env, &learner, current_balance); storage::set_total_supply(&env, current_supply); } @@ -923,7 +923,13 @@ impl LearnToken { } /// Update the maximum supply cap. Admin only. + /// + /// # Governance Safeguard + /// To prevent arbitrary or unlimited supply inflation by a compromised admin key, + /// the cap can never be increased by more than 2x (100% increase) in a single update. + /// Decreasing the cap is allowed down to the circulating total supply. pub fn set_max_supply(env: Env, new_max_supply: i128) { + Self::require_not_paused(&env); let admin = storage::get_admin(&env); admin.require_auth(); if new_max_supply < 0 { @@ -933,7 +939,15 @@ impl LearnToken { if new_max_supply < current_supply { panic!("new cap cannot be less than current total supply"); } + let old_max_supply = storage::get_max_supply(&env); + if old_max_supply > 0 && new_max_supply > old_max_supply { + let max_allowed = old_max_supply.checked_mul(2).expect("overflow"); + if new_max_supply > max_allowed { + panic!("max supply increase exceeds governance limit (maximum 2x increase per update)"); + } + } storage::set_max_supply(&env, new_max_supply); + events::max_supply_updated(&env, old_max_supply, new_max_supply); } /// Transfer admin rights to a new address. diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index f502f1e..8bb5a8e 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -361,6 +361,7 @@ pub fn check_allowance_expired(env: &Env, owner: &Address, spender: &Address) -> } /// Read-only version of check_allowance_expired that does not perform storage side-effects. +#[allow(dead_code)] pub fn check_allowance_expired_readonly(env: &Env, owner: &Address, spender: &Address) -> (bool, bool, u32) { let key = AllowanceKey { owner: owner.clone(), @@ -732,20 +733,6 @@ pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { ); } -/// Whether the contract is currently paused (#238). -pub fn is_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get(&TokenDataKey::Paused) - .unwrap_or(false) -} - -/// Set the paused flag (#238). -pub fn set_paused(env: &Env, paused: bool) { - env.storage() - .persistent() - .set(&TokenDataKey::Paused, &paused); -} // ── Vesting Schedules (#225) ────────────────────────────────────────────────── diff --git a/tests/integration/full_flow.rs b/tests/integration/full_flow.rs index 0504583..95d7bdd 100644 --- a/tests/integration/full_flow.rs +++ b/tests/integration/full_flow.rs @@ -13,7 +13,7 @@ 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 _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); diff --git a/tests/unit/credential_tests.rs b/tests/unit/credential_tests.rs index d244230..929e080 100644 --- a/tests/unit/credential_tests.rs +++ b/tests/unit/credential_tests.rs @@ -241,4 +241,74 @@ mod credential_unit_tests { // We do not mock auths, so revoke_credential must fail admin auth check client.revoke_credential(&1); } + + #[test] + #[should_panic(expected = "metadata_uri cannot be empty")] + fn test_mint_rejects_empty_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course_id, 85); + + let empty_uri = Symbol::new(&env, ""); + client.mint_credential(&learner, &course_id, &85, &empty_uri); + } + + #[test] + #[should_panic(expected = "metadata_uri too short: minimum length is 8")] + fn test_mint_rejects_too_short_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course_id, 85); + + let short_uri = Symbol::new(&env, "ipfs_1"); + client.mint_credential(&learner, &course_id, &85, &short_uri); + } + + #[test] + #[should_panic(expected = "metadata_uri is malformed: must start with a valid URI scheme")] + fn test_mint_rejects_malformed_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course_id, 85); + + let malformed_uri = Symbol::new(&env, "ftp_metadata_hash"); + client.mint_credential(&learner, &course_id, &85, &malformed_uri); + } + + #[test] + fn test_mint_accepts_valid_metadata_uri() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course_id, 85); + + let valid_uri = Symbol::new(&env, "ipfs_Qm123ValidURI"); + let cred_id = client.mint_credential(&learner, &course_id, &85, &valid_uri); + assert_eq!(cred_id, 1); + let info = client.verify_credential(&cred_id); + assert_eq!(info.metadata_uri, valid_uri); + } } diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index cf9b160..10b40b8 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -3,7 +3,7 @@ use learn_token::{LearnToken, LearnTokenClient}; use progress_tracker::{ProgressTracker, ProgressTrackerClient}; use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, + testutils::{Address as _, Events as _, Ledger as _}, Address, Env, IntoVal, String as SorobanString, Symbol, Vec, }; @@ -113,7 +113,7 @@ mod token_unit_tests { #[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); @@ -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); @@ -367,6 +367,114 @@ mod token_unit_tests { client.set_max_supply(&2000); } + #[test] + fn test_set_max_supply_emits_event() { + let env = Env::default(); + 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, 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, + &5000, + ); + + env.mock_all_auths(); + + client.set_max_supply(&8000); + assert_eq!(client.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.clone(), + (Symbol::new(&env, "max_supply_updated"),).into_val(&env), + (5000i128, 8000i128).into_val(&env), + ) + ] + ); + } + + #[test] + #[should_panic(expected = "max supply increase exceeds governance limit")] + fn test_set_max_supply_rejects_exceeding_2x_increase() { + let env = Env::default(); + 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, 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, + &5000, + ); + + env.mock_all_auths(); + + // 5000 -> 15000 is 3x increase, exceeding the 2x limit (max 10000) + client.set_max_supply(&15000); + } + + #[test] + fn test_set_max_supply_allows_reduction() { + let env = Env::default(); + 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, 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, + &5000, + ); + + env.mock_all_auths(); + + client.mint(&admin, &Address::generate(&env), &1000); + assert_eq!(client.total_supply(), 1000); + + // Reducing from 5000 to 3000 (above current supply 1000) is allowed + client.set_max_supply(&3000); + assert_eq!(client.max_supply(), 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.clone(), + (Symbol::new(&env, "max_supply_updated"),).into_val(&env), + (5000i128, 3000i128).into_val(&env), + ) + ] + ); + } + #[test] #[should_panic(expected = "cannot mint to zero address")] fn test_mint_to_zero_address_panics() { @@ -448,10 +556,8 @@ mod token_unit_tests { #[test] fn test_reward_claimed_event_indexes_learner_and_course() { - 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);