Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions chainlearn-api/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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/**',
],
},
},
});
20 changes: 20 additions & 0 deletions chainlearn-indexer/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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/**',
],
},
},
});
1 change: 1 addition & 0 deletions contracts/credential-nft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
72 changes: 72 additions & 0 deletions contracts/credential-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
34 changes: 33 additions & 1 deletion contracts/credential-nft/src/mint.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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!(
Expand Down
9 changes: 9 additions & 0 deletions contracts/learn-token/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
16 changes: 15 additions & 1 deletion contracts/learn-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
15 changes: 1 addition & 14 deletions contracts/learn-token/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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) ──────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/full_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/credential_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading