Skip to content

Commit 8b3414f

Browse files
Merge pull request #184 from salazarsebas/feat/issue-1-student-certificate-lookup
feat(contract): add student-centric certificate lookup
2 parents c5df738 + 8f41d47 commit 8b3414f

3 files changed

Lines changed: 104 additions & 30 deletions

File tree

contracts/src/lib.rs

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ pub struct CertKey {
7575
pub student: Address,
7676
}
7777

78+
#[contracttype]
79+
#[derive(Clone)]
80+
pub struct StudentCertificatesKey {
81+
pub student: Address,
82+
}
83+
7884
#[contracttype]
7985
#[derive(Clone)]
8086
pub struct MetaTxCallData {
@@ -324,6 +330,48 @@ impl CertificateContract {
324330
.set(&DataKey::MintedThisPeriod, &new_minted);
325331
}
326332

333+
fn persist_certificate(env: &Env, cert: &Certificate) {
334+
let key = CertKey {
335+
course_symbol: cert.course_symbol.clone(),
336+
student: cert.student.clone(),
337+
};
338+
339+
env.storage().persistent().set(&key, cert);
340+
env.storage()
341+
.persistent()
342+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
343+
344+
Self::index_certificate_for_student(env, &cert.student, &cert.course_symbol);
345+
}
346+
347+
fn index_certificate_for_student(env: &Env, student: &Address, course_symbol: &Symbol) {
348+
let index_key = StudentCertificatesKey {
349+
student: student.clone(),
350+
};
351+
let mut course_symbols: Vec<Symbol> = env
352+
.storage()
353+
.persistent()
354+
.get(&index_key)
355+
.unwrap_or_else(|| Vec::new(env));
356+
357+
let mut already_indexed = false;
358+
for indexed_symbol in course_symbols.iter() {
359+
if indexed_symbol == *course_symbol {
360+
already_indexed = true;
361+
break;
362+
}
363+
}
364+
365+
if !already_indexed {
366+
course_symbols.push_back(course_symbol.clone());
367+
env.storage().persistent().set(&index_key, &course_symbols);
368+
}
369+
370+
env.storage()
371+
.persistent()
372+
.extend_ttl(&index_key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
373+
}
374+
327375
/// Returns true if `account` has `role`. `Admin` matches the three governance addresses only.
328376
pub fn has_role(env: Env, account: Address, role: Role) -> bool {
329377
match role {
@@ -536,11 +584,6 @@ impl CertificateContract {
536584
let mut issued: Vec<Certificate> = Vec::new(&env);
537585

538586
for student in students.iter() {
539-
let key = CertKey {
540-
course_symbol: course_symbol.clone(),
541-
student: student.clone(),
542-
};
543-
544587
let cert = Certificate {
545588
course_symbol: course_symbol.clone(),
546589
student: student.clone(),
@@ -549,16 +592,11 @@ impl CertificateContract {
549592
revoked: false,
550593
};
551594

552-
env.storage().persistent().set(&key, &cert);
553-
env.storage()
554-
.persistent()
555-
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
556-
595+
Self::persist_certificate(&env, &cert);
557596
env.events().publish(
558597
(Symbol::new(&env, "v1_cert_issued"), course_symbol.clone()),
559598
(student.clone(), course_name.clone()),
560599
);
561-
562600
issued.push_back(cert);
563601
}
564602

@@ -611,11 +649,6 @@ impl CertificateContract {
611649
let course_symbol = symbols.get(i).unwrap();
612650
let student = students.get(i).unwrap();
613651

614-
let key = CertKey {
615-
course_symbol: course_symbol.clone(),
616-
student: student.clone(),
617-
};
618-
619652
let cert = Certificate {
620653
course_symbol: course_symbol.clone(),
621654
student: student.clone(),
@@ -624,11 +657,7 @@ impl CertificateContract {
624657
revoked: false,
625658
};
626659

627-
// Batch storage operations for efficiency
628-
env.storage().persistent().set(&key, &cert);
629-
env.storage()
630-
.persistent()
631-
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
660+
Self::persist_certificate(&env, &cert);
632661

633662
// Batch event emission (emit one event per certificate for transparency)
634663
env.events().publish(
@@ -699,6 +728,29 @@ impl CertificateContract {
699728
env.storage().persistent().get(&key)
700729
}
701730

731+
/// Returns every certificate indexed for a student across all course symbols.
732+
pub fn get_certificates_by_student(env: Env, student: Address) -> Vec<Certificate> {
733+
let index_key = StudentCertificatesKey {
734+
student: student.clone(),
735+
};
736+
let course_symbols: Vec<Symbol> = env
737+
.storage()
738+
.persistent()
739+
.get(&index_key)
740+
.unwrap_or_else(|| Vec::new(&env));
741+
let mut certificates = Vec::new(&env);
742+
743+
for course_symbol in course_symbols.iter() {
744+
if let Some(cert) =
745+
Self::get_certificate(env.clone(), course_symbol.clone(), student.clone())
746+
{
747+
certificates.push_back(cert);
748+
}
749+
}
750+
751+
certificates
752+
}
753+
702754
/// Extend the TTL of a certificate entry in persistent storage.
703755
/// The student (or a governance admin) pays for the storage rent extension.
704756
pub fn renew_certificate(env: Env, caller: Address, course_symbol: Symbol, student: Address) {
@@ -781,11 +833,6 @@ impl CertificateContract {
781833
.set(&nonce_key, &(stored_nonce + 1));
782834

783835
let issue_date = env.ledger().timestamp();
784-
let key = CertKey {
785-
course_symbol: call_data.course_symbol.clone(),
786-
student: call_data.student.clone(),
787-
};
788-
789836
let available = Self::check_and_update_mint_tracking(&env);
790837
if available < 1 {
791838
Self::release_lock(&env);
@@ -801,10 +848,7 @@ impl CertificateContract {
801848
revoked: false,
802849
};
803850

804-
env.storage().persistent().set(&key, &cert);
805-
env.storage()
806-
.persistent()
807-
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
851+
Self::persist_certificate(&env, &cert);
808852

809853
env.events().publish(
810854
(

contracts/src/tests.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,33 @@ fn verifies_event_emitted_per_student() {
154154
assert_eq!(cert_issued_count, 2);
155155
}
156156

157+
#[test]
158+
fn gets_certificates_by_student_across_courses() {
159+
let (env, instructor, _, _, client) = setup();
160+
161+
let student = Address::generate(&env);
162+
let course_name = String::from_str(&env, "Soroban");
163+
164+
client.issue(
165+
&instructor,
166+
&symbol_short!("RUST"),
167+
&vec![&env, student.clone()],
168+
&course_name,
169+
);
170+
client.issue(
171+
&instructor,
172+
&symbol_short!("WEB3"),
173+
&vec![&env, student.clone()],
174+
&course_name,
175+
);
176+
177+
let certificates = client.get_certificates_by_student(&student);
178+
179+
assert_eq!(certificates.len(), 2);
180+
assert_eq!(certificates.get(0).unwrap().student, student);
181+
assert_eq!(certificates.get(1).unwrap().student, student);
182+
}
183+
157184
#[test]
158185
fn admin_can_revoke_certificate() {
159186
let (env, admin, _, _, client) = setup();
@@ -846,6 +873,8 @@ fn batch_issue_gas_efficiency() {
846873
"BATCH9",
847874
];
848875

876+
for symbol_name in symbol_names {
877+
symbols.push_back(Symbol::new(&env, symbol_name));
849878
for name in &symbol_names {
850879
symbols.push_back(Symbol::new(&env, name));
851880
students.push_back(Address::generate(&env));

contracts/src/token.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ mod tests {
795795
// Verify all required fields are present and have correct types
796796
assert!(!metadata.name.is_empty());
797797
assert!(!metadata.symbol.is_empty());
798+
assert_eq!(metadata.decimals, 0);
798799
assert!(!metadata.uri.is_empty());
799800

800801
// Verify symbol is reasonable length (common token symbols are 3-5 chars)

0 commit comments

Comments
 (0)