Skip to content

Commit ffb0691

Browse files
authored
Merge pull request #572 from solomonokeke012-bot/feature/426-student-outcome-tracker
feat: Add Student Outcome Tracking contract (#426)
2 parents 82fae4b + 51d713b commit ffb0691

4 files changed

Lines changed: 417 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "student-outcome-tracker"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "A contract for tracking post-graduation student outcomes including career placement, salary, job satisfaction, and impact metrics with privacy preservation"
6+
license = "Apache-2.0"
7+
repository = "https://github.com/StarkMindsHQ/StrellerMinds-SmartContracts"
8+
keywords = ["stellar", "blockchain", "education", "outcomes", "soroban"]
9+
categories = ["cryptography::cryptocurrencies", "web-programming"]
10+
11+
[lib]
12+
crate-type = ["lib", "cdylib"]
13+
14+
[dependencies]
15+
soroban-sdk = { workspace = true }
16+
shared = { path = "../shared" }
17+
18+
[dev-dependencies]
19+
soroban-sdk = { workspace = true, features = ["testutils"] }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use soroban_sdk::contracterror;
2+
3+
#[contracterror]
4+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
5+
#[repr(u32)]
6+
pub enum OutcomeError {
7+
// Initialization (1-9)
8+
AlreadyInitialized = 1,
9+
NotInitialized = 2,
10+
// Authorization (10-19)
11+
Unauthorized = 10,
12+
AdminNotSet = 11,
13+
// Validation (20-49)
14+
OutcomeNotFound = 20,
15+
InvalidSatisfactionScore = 21,
16+
InvalidSalary = 22,
17+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#![no_std]
2+
3+
pub mod errors;
4+
5+
use crate::errors::OutcomeError;
6+
use shared::event_schema::{
7+
AccessControlEventData, ContractInitializedEvent,
8+
};
9+
use shared::monitoring::{ContractHealthReport, Monitor};
10+
use shared::emit_access_control_event;
11+
use shared::gas_optimizer::{TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR};
12+
use soroban_sdk::{
13+
contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol,
14+
};
15+
16+
/// Employment status of a graduate.
17+
#[derive(Clone, Debug, Eq, PartialEq)]
18+
#[contracttype]
19+
pub enum EmploymentStatus {
20+
Employed,
21+
Unemployed,
22+
SelfEmployed,
23+
FurtherStudy,
24+
Unknown,
25+
}
26+
27+
/// Privacy-preserving outcome record for a graduate.
28+
/// Salary is stored as a range bucket (e.g. 0=<30k, 1=30-60k, 2=60-100k, 3=100k+)
29+
/// to avoid exposing exact figures on-chain.
30+
#[derive(Clone)]
31+
#[contracttype]
32+
pub struct StudentOutcome {
33+
/// Hashed/anonymised student identifier (caller provides their own hash)
34+
pub student_id: Symbol,
35+
/// Employment status post-graduation
36+
pub employment_status: EmploymentStatus,
37+
/// Salary range bucket (0–3); u32::MAX means not disclosed
38+
pub salary_range: u32,
39+
/// Job satisfaction score 1–10
40+
pub satisfaction_score: u32,
41+
/// Free-form impact metric tag (e.g. "promoted", "startup_founded")
42+
pub impact_tag: Symbol,
43+
/// Ledger timestamp of last update
44+
pub updated_at: u64,
45+
}
46+
47+
#[contracttype]
48+
enum DataKey {
49+
Admin,
50+
Outcome(Symbol), // keyed by student_id symbol
51+
}
52+
53+
#[contract]
54+
pub struct OutcomeTracker;
55+
56+
#[contractimpl]
57+
impl OutcomeTracker {
58+
/// Initialise the contract and set the admin.
59+
pub fn initialize(env: Env, admin: Address) -> Result<(), OutcomeError> {
60+
if env.storage().instance().has(&DataKey::Admin) {
61+
return Err(OutcomeError::AlreadyInitialized);
62+
}
63+
admin.require_auth();
64+
env.storage().instance().set(&DataKey::Admin, &admin);
65+
env.storage()
66+
.instance()
67+
.extend_ttl(TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR);
68+
emit_access_control_event!(
69+
&env,
70+
symbol_short!("outcome"),
71+
admin.clone(),
72+
AccessControlEventData::ContractInitialized(ContractInitializedEvent { admin })
73+
);
74+
Ok(())
75+
}
76+
77+
/// Record or update a student's post-graduation outcome.
78+
///
79+
/// Only the admin may submit outcome data to protect student privacy.
80+
///
81+
/// # Arguments
82+
/// * `student_id` – Anonymised identifier (e.g. hash of student address).
83+
/// * `employment_status` – Current employment status.
84+
/// * `salary_range` – Salary bucket 0–3, or u32::MAX for undisclosed.
85+
/// * `satisfaction_score`– Job satisfaction 1–10.
86+
/// * `impact_tag` – Short impact descriptor symbol.
87+
pub fn record_outcome(
88+
env: Env,
89+
student_id: Symbol,
90+
employment_status: EmploymentStatus,
91+
salary_range: u32,
92+
satisfaction_score: u32,
93+
impact_tag: Symbol,
94+
) -> Result<(), OutcomeError> {
95+
// salary_range: 0-3 or u32::MAX (undisclosed)
96+
if salary_range > 3 && salary_range != u32::MAX {
97+
return Err(OutcomeError::InvalidSalary);
98+
}
99+
if satisfaction_score < 1 || satisfaction_score > 10 {
100+
return Err(OutcomeError::InvalidSatisfactionScore);
101+
}
102+
103+
let admin: Address = env
104+
.storage()
105+
.instance()
106+
.get(&DataKey::Admin)
107+
.ok_or(OutcomeError::AdminNotSet)?;
108+
admin.require_auth();
109+
110+
let outcome = StudentOutcome {
111+
student_id: student_id.clone(),
112+
employment_status,
113+
salary_range,
114+
satisfaction_score,
115+
impact_tag,
116+
updated_at: env.ledger().timestamp(),
117+
};
118+
119+
let key = DataKey::Outcome(student_id);
120+
env.storage().persistent().set(&key, &outcome);
121+
env.storage()
122+
.persistent()
123+
.extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR);
124+
env.storage()
125+
.instance()
126+
.extend_ttl(TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR);
127+
128+
env.events().publish(
129+
(symbol_short!("outcome"), symbol_short!("recorded")),
130+
outcome.student_id,
131+
);
132+
133+
Ok(())
134+
}
135+
136+
/// Retrieve a student's outcome record.
137+
pub fn get_outcome(
138+
env: Env,
139+
student_id: Symbol,
140+
) -> Result<StudentOutcome, OutcomeError> {
141+
let key = DataKey::Outcome(student_id);
142+
let outcome: StudentOutcome = env
143+
.storage()
144+
.persistent()
145+
.get(&key)
146+
.ok_or(OutcomeError::OutcomeNotFound)?;
147+
env.storage()
148+
.persistent()
149+
.extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR);
150+
env.storage()
151+
.instance()
152+
.extend_ttl(TTL_BUMP_THRESHOLD, TTL_PERSISTENT_YEAR);
153+
Ok(outcome)
154+
}
155+
156+
/// Return the admin address.
157+
pub fn get_admin(env: Env) -> Result<Address, OutcomeError> {
158+
env.storage()
159+
.instance()
160+
.get(&DataKey::Admin)
161+
.ok_or(OutcomeError::AdminNotSet)
162+
}
163+
164+
/// Health check for monitoring.
165+
pub fn health_check(env: Env) -> ContractHealthReport {
166+
let initialized = env.storage().instance().has(&DataKey::Admin);
167+
let report = Monitor::build_health_report(&env, symbol_short!("outcome"), initialized);
168+
Monitor::emit_health_check(&env, &report);
169+
report
170+
}
171+
}
172+
173+
#[cfg(test)]
174+
mod test;

0 commit comments

Comments
 (0)