diff --git a/contracts/teachlink/src/bridge.rs b/contracts/teachlink/src/bridge.rs index 1adf913..6507ac3 100644 --- a/contracts/teachlink/src/bridge.rs +++ b/contracts/teachlink/src/bridge.rs @@ -383,4 +383,9 @@ impl Bridge { pub fn get_token(env: &Env) -> Address { env.storage().instance().get(&TOKEN).unwrap() } + + /// Get the admin address + pub fn get_admin(env: &Env) -> Address { + env.storage().instance().get(&ADMIN).unwrap() + } } diff --git a/contracts/teachlink/src/events.rs b/contracts/teachlink/src/events.rs index 91ba6ad..75ba116 100644 --- a/contracts/teachlink/src/events.rs +++ b/contracts/teachlink/src/events.rs @@ -1,6 +1,11 @@ use soroban_sdk::contractevent; -use crate::types::{BridgeTransaction, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus}; +use crate::types::{ + BridgeTransaction, ContributionType, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus, + // Added implied types from main branch events to ensure compilation + ContentMetadata, ProvenanceRecord, +}; + use soroban_sdk::{Address, Bytes, String}; #[contractevent] @@ -108,7 +113,32 @@ pub struct EscrowResolvedEvent { pub status: EscrowStatus, } -// ========== Content Tokenization Events ========== +// ========== Credit Score Events (feat/credit_score) ========== + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CreditScoreUpdatedEvent { + pub user: Address, + pub new_score: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CourseCompletedEvent { + pub user: Address, + pub course_id: u64, + pub points: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ContributionRecordedEvent { + pub user: Address, + pub c_type: ContributionType, + pub points: u64, +} + +// ========== Content Tokenization Events (main) ========== #[contractevent] #[derive(Clone, Debug)] @@ -140,4 +170,4 @@ pub struct MetadataUpdatedEvent { pub token_id: u64, pub owner: Address, pub timestamp: u64, -} +} \ No newline at end of file diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index 53fd3e3..b1e1ebb 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -5,15 +5,18 @@ use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, String, Vec}; mod bridge; mod escrow; mod events; +mod provenance; mod reputation; mod rewards; +mod score; mod storage; mod tokenization; mod types; pub use types::{ - BridgeTransaction, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus, RewardRate, - UserReward, + BridgeTransaction, ContentToken, ContentType, Contribution, ContributionType, + CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus, ProvenanceRecord, RewardRate, + UserReputation, UserReward, }; #[contract] @@ -257,7 +260,45 @@ impl TeachLinkBridge { escrow::EscrowManager::get_escrow_count(&env) } - // ========== Reputation Functions ========== + // ========== Credit Scoring Functions (feat/credit_score) ========== + + /// Record a course completion (admin only for now, or specific authority) + pub fn record_course_completion(env: Env, user: Address, course_id: u64, points: u64) { + // require admin + let admin = bridge::Bridge::get_admin(&env); + admin.require_auth(); + score::ScoreManager::record_course_completion(&env, user, course_id, points); + } + + /// Record a contribution (admin only) + pub fn record_contribution( + env: Env, + user: Address, + c_type: types::ContributionType, + description: Bytes, + points: u64, + ) { + let admin = bridge::Bridge::get_admin(&env); + admin.require_auth(); + score::ScoreManager::record_contribution(&env, user, c_type, description, points); + } + + /// Get user's credit score + pub fn get_credit_score(env: Env, user: Address) -> u64 { + score::ScoreManager::get_score(&env, user) + } + + /// Get user's completed courses + pub fn get_user_courses(env: Env, user: Address) -> Vec { + score::ScoreManager::get_courses(&env, user) + } + + /// Get user's contributions + pub fn get_user_contributions(env: Env, user: Address) -> Vec { + score::ScoreManager::get_contributions(&env, user) + } + + // ========== Reputation Functions (main) ========== pub fn update_participation(env: Env, user: Address, points: u32) { reputation::update_participation(&env, user, points); @@ -273,6 +314,8 @@ impl TeachLinkBridge { pub fn get_user_reputation(env: Env, user: Address) -> types::UserReputation { reputation::get_reputation(&env, &user) + } + // ========== Content Tokenization Functions ========== /// Mint a new educational content token @@ -398,4 +441,4 @@ impl TeachLinkBridge { pub fn get_content_all_owners(env: Env, token_id: u64) -> Vec
{ provenance::ProvenanceTracker::get_all_owners(&env, token_id) } -} +} \ No newline at end of file diff --git a/contracts/teachlink/src/score.rs b/contracts/teachlink/src/score.rs new file mode 100644 index 0000000..a22ea29 --- /dev/null +++ b/contracts/teachlink/src/score.rs @@ -0,0 +1,108 @@ +use crate::events::{ContributionRecordedEvent, CourseCompletedEvent, CreditScoreUpdatedEvent}; +use crate::storage::{CONTRIBUTIONS, COURSE_COMPLETIONS, CREDIT_SCORE}; +use crate::types::{Contribution, ContributionType}; +use soroban_sdk::{Address, Bytes, Env, Vec}; + +pub struct ScoreManager; + +impl ScoreManager { + /// Update the user's score by adding points + pub fn update_score(env: &Env, user: Address, points: u64) { + // Use a tuple key (CREDIT_SCORE, user) for mapping user to score + let key = (CREDIT_SCORE, user.clone()); + let current_score: u64 = env.storage().persistent().get(&key).unwrap_or(0); + let new_score = current_score + points; + env.storage().persistent().set(&key, &new_score); + + CreditScoreUpdatedEvent { user, new_score }.publish(env); + } + + /// Record a course completion and award points + pub fn record_course_completion(env: &Env, user: Address, course_id: u64, points: u64) { + let key = (COURSE_COMPLETIONS, user.clone()); + let mut completed_courses: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)); + + // Avoid duplicate points for the same course + if completed_courses.contains(course_id) { + return; // Already completed + } + + completed_courses.push_back(course_id); + env.storage().persistent().set(&key, &completed_courses); + + // Update score internally + Self::update_score(env, user.clone(), points); + + CourseCompletedEvent { + user, + course_id, + points, + } + .publish(env); + } + + /// Record a contribution and award points + pub fn record_contribution( + env: &Env, + user: Address, + c_type: ContributionType, + description: Bytes, + points: u64, + ) { + let key = (CONTRIBUTIONS, user.clone()); + let mut contributions: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)); + + let contribution = Contribution { + contributor: user.clone(), + c_type: c_type.clone(), + description, + timestamp: env.ledger().timestamp(), + points, + }; + + contributions.push_back(contribution); + env.storage().persistent().set(&key, &contributions); + + // Update score internally + Self::update_score(env, user.clone(), points); + + ContributionRecordedEvent { + user, + c_type, + points, + } + .publish(env); + } + + /// Get the user's current credit score + pub fn get_score(env: &Env, user: Address) -> u64 { + env.storage() + .persistent() + .get(&(CREDIT_SCORE, user)) + .unwrap_or(0) + } + + /// Get valid course completions + pub fn get_courses(env: &Env, user: Address) -> Vec { + env.storage() + .persistent() + .get(&(COURSE_COMPLETIONS, user)) + .unwrap_or(Vec::new(env)) + } + + /// Get user contributions + pub fn get_contributions(env: &Env, user: Address) -> Vec { + env.storage() + .persistent() + .get(&(CONTRIBUTIONS, user)) + .unwrap_or(Vec::new(env)) + } +} diff --git a/contracts/teachlink/src/storage.rs b/contracts/teachlink/src/storage.rs index 0167924..737e360 100644 --- a/contracts/teachlink/src/storage.rs +++ b/contracts/teachlink/src/storage.rs @@ -21,6 +21,11 @@ pub const TOTAL_REWARDS_ISSUED: Symbol = symbol_short!("tot_rwds"); pub const ESCROW_COUNT: Symbol = symbol_short!("esc_ct"); pub const ESCROWS: Symbol = symbol_short!("escrows"); +// Storage keys for credit scoring +pub const CREDIT_SCORE: Symbol = symbol_short!("score"); +pub const COURSE_COMPLETIONS: Symbol = symbol_short!("courses"); +pub const CONTRIBUTIONS: Symbol = symbol_short!("contribs"); + // Storage keys for content tokenization pub const TOKEN_COUNTER: Symbol = symbol_short!("tok_cnt"); pub const CONTENT_TOKENS: Symbol = symbol_short!("cnt_tok"); diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 3b41158..d515c96 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -85,6 +85,29 @@ pub enum DisputeOutcome { RefundToDepositor, } +// ========== Credit Score / Contribution Types (feat/credit_score) ========== + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContributionType { + Content, + Code, + Community, + Governance, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Contribution { + pub contributor: Address, + pub c_type: ContributionType, + pub description: Bytes, + pub timestamp: u64, + pub points: u64, +} + +// ========== Reputation Types (main) ========== + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct UserReputation { @@ -95,6 +118,8 @@ pub struct UserReputation { pub total_courses_completed: u32, pub total_contributions: u32, pub last_update: u64, +} + // ========== Educational Content Tokenization Types ========== #[contracttype] @@ -146,11 +171,9 @@ pub struct ProvenanceRecord { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum DataKey { - Reputation(Address), pub enum TransferType { Mint, // Initial creation Transfer, // Standard ownership transfer License, // Licensing agreement Revoke, // Ownership revoked -} +} \ No newline at end of file diff --git a/contracts/teachlink/test_snapshots/test_credit_scoring_flow.1.json b/contracts/teachlink/test_snapshots/test_credit_scoring_flow.1.json new file mode 100644 index 0000000..3c0e958 --- /dev/null +++ b/contracts/teachlink/test_snapshots/test_credit_scoring_flow.1.json @@ -0,0 +1,457 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "record_course_completion", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "u64": "101" + }, + { + "u64": "50" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "record_course_completion", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "u64": "101" + }, + { + "u64": "50" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "record_course_completion", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "u64": "102" + }, + { + "u64": "30" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "record_contribution", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "vec": [ + { + "symbol": "Content" + } + ] + }, + { + "bytes": "466978656420612062756720696e20646f6373" + }, + { + "u64": "20" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "contribs" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "map": [ + { + "key": { + "symbol": "c_type" + }, + "val": { + "vec": [ + { + "symbol": "Content" + } + ] + } + }, + { + "key": { + "symbol": "contributor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "description" + }, + "val": { + "bytes": "466978656420612062756720696e20646f6373" + } + }, + { + "key": { + "symbol": "points" + }, + "val": { + "u64": "20" + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": "0" + } + } + ] + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "courses" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "u64": "101" + }, + { + "u64": "102" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "score" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "100" + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "bridgefee" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "chains" + }, + "val": { + "map": [] + } + }, + { + "key": { + "symbol": "fee_rcpt" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "min_valid" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "nonce" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "validtor" + }, + "val": { + "map": [] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/teachlink/tests/test_score.rs b/contracts/teachlink/tests/test_score.rs new file mode 100644 index 0000000..ba812c5 --- /dev/null +++ b/contracts/teachlink/tests/test_score.rs @@ -0,0 +1,62 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env}; +use teachlink_contract::{ContributionType, TeachLinkBridge, TeachLinkBridgeClient}; + +#[test] +fn test_credit_scoring_flow() { + let env = Env::default(); + env.mock_all_auths(); + + // Initialize contract + let contract_id = env.register(TeachLinkBridge, ()); + let client = TeachLinkBridgeClient::new(&env, &contract_id); + + let token = Address::generate(&env); + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + + // Initialize + client.initialize(&token, &admin, &1, &fee_recipient); + + let user = Address::generate(&env); + + // Initial score should be 0 + assert_eq!(client.get_credit_score(&user), 0); + + // Record course completion + let course_id = 101u64; + let points = 50u64; + client.record_course_completion(&user, &course_id, &points); + + // Score should update + assert_eq!(client.get_credit_score(&user), 50); + + // Duplicate course completion should not add points + client.record_course_completion(&user, &course_id, &points); + assert_eq!(client.get_credit_score(&user), 50); + + // Another course + client.record_course_completion(&user, &102u64, &30u64); + assert_eq!(client.get_credit_score(&user), 80); + + // Check courses list + let courses = client.get_user_courses(&user); + assert_eq!(courses.len(), 2); + assert!(courses.contains(101)); + assert!(courses.contains(102)); + + // Record contribution + let desc = Bytes::from_slice(&env, b"Fixed a bug in docs"); + client.record_contribution(&user, &ContributionType::Content, &desc, &20u64); + + // Score should update: 80 + 20 = 100 + assert_eq!(client.get_credit_score(&user), 100); + + // Check contributions + let contributions = client.get_user_contributions(&user); + assert_eq!(contributions.len(), 1); + let c = contributions.get(0).unwrap(); + assert_eq!(c.points, 20); + assert_eq!(c.contributor, user); +}