From 5b03743c39ea30ab62d70a1f29520b859a0e44ea Mon Sep 17 00:00:00 2001 From: Ikem Peter Date: Fri, 23 Jan 2026 16:54:47 +0100 Subject: [PATCH] feat: implement advanced credit scoring with on-chain history --- contracts/teachlink/src/bridge.rs | 5 + contracts/teachlink/src/events.rs | 28 +- contracts/teachlink/src/lib.rs | 42 +- contracts/teachlink/src/score.rs | 108 +++++ contracts/teachlink/src/storage.rs | 5 + contracts/teachlink/src/types.rs | 19 + .../test_credit_scoring_flow.1.json | 457 ++++++++++++++++++ contracts/teachlink/tests/test_score.rs | 62 +++ 8 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 contracts/teachlink/src/score.rs create mode 100644 contracts/teachlink/test_snapshots/test_credit_scoring_flow.1.json create mode 100644 contracts/teachlink/tests/test_score.rs 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 e527cd8..c3225f2 100644 --- a/contracts/teachlink/src/events.rs +++ b/contracts/teachlink/src/events.rs @@ -1,6 +1,9 @@ use soroban_sdk::contractevent; -use crate::types::{BridgeTransaction, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus}; +use crate::types::{ + BridgeTransaction, ContributionType, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus, +}; + use soroban_sdk::{Address, Bytes}; #[contractevent] @@ -81,3 +84,26 @@ pub struct EscrowResolvedEvent { pub outcome: DisputeOutcome, pub status: EscrowStatus, } + +#[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, +} diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index feb616c..87bc805 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -5,11 +5,13 @@ use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec}; mod bridge; mod escrow; mod events; +mod score; mod storage; mod types; pub use types::{ - BridgeTransaction, CrossChainMessage, DisputeOutcome, Escrow, EscrowStatus, + BridgeTransaction, Contribution, ContributionType, CrossChainMessage, DisputeOutcome, Escrow, + EscrowStatus, }; #[contract] @@ -195,4 +197,42 @@ impl TeachLinkBridge { pub fn get_escrow_count(env: Env) -> u64 { escrow::EscrowManager::get_escrow_count(&env) } + + // ========== Credit Scoring Functions ========== + + /// 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) + } } 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 8c44cf0..02ae85d 100644 --- a/contracts/teachlink/src/storage.rs +++ b/contracts/teachlink/src/storage.rs @@ -13,3 +13,8 @@ pub const FEE_RECIPIENT: Symbol = symbol_short!("fee_rcpt"); pub const BRIDGE_FEE: Symbol = symbol_short!("bridgefee"); 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"); diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 7dac70a..c9c6e47 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -66,3 +66,22 @@ pub enum DisputeOutcome { ReleaseToBeneficiary, RefundToDepositor, } + +#[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, +} 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); +}