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
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
# Hamplard — Contract Repo
# Hamplard — Contract Repo

> **On-chain course enrollment, payments, and certificate issuance on Stellar Soroban**

Hamplard is an online learning platform for practical vocational skills — tailoring, makeup artistry, baking, photography, hairstyling, nail technology, fashion design, and more. This Soroban smart contract handles the trustless financial and credential layer of the platform: course enrollment payments, automatic instructor revenue splits, and verifiable on-chain certificates of completion.

## Short version

Hamplard Contracts is the Soroban smart contract layer for the Hamplard platform. It manages course lifecycle state, enrollment payments, instructor revenue splits, certificate issuance, and revocation in a trustless and auditable way.

The contract provides the core rules for:
- registering and approving courses
- pausing and unpausing courses
- archiving courses permanently
- enrolling students and splitting payments automatically
- marking enrollments complete
- issuing and revoking certificates
- emitting events for off-chain indexing and backend synchronization

### Main contract concepts
- Courses move through a lifecycle from Pending to Active, Paused, and Archived.
- Enrollments trigger automatic platform and instructor payment splits.
- Certificates can be issued after completion and later revoked with metadata captured on-chain.

### Key features
- multi-admin authorization for sensitive operations
- course status enforcement and lifecycle rules
- instructor earnings accounting
- certificate issuance and revocation tracking
- Soroban event emission for off-chain systems and indexers

The detailed documentation below expands on the architecture, data model, contract functions, and deployment flow.

This is **Repo 1 of 3** in the Hamplard project:

| Repo | Description |
Expand Down Expand Up @@ -198,7 +225,7 @@ Initialises the contract. Called once by the deployer.
### Read-only queries

| Function | Returns |
|---|---|
|---|---|---|
| `get_course(course_id)` | Full `Course` struct |
| `get_enrollment(student, course_id)` | Full `Enrollment` struct |
| `get_certificate(certificate_id)` | Full `Certificate` struct |
Expand Down Expand Up @@ -337,7 +364,7 @@ test test::test_register_course_custom_fee .............. ok
test test::test_register_course_success ................. ok
test test::test_register_duplicate_course ............... ok
test test::test_revoke_certificate ...................... ok
test test::test_update_platform_fee ..................... ok
test test::test_update_platform_fee ...................... ok
```

---
Expand Down
33 changes: 31 additions & 2 deletions contracts/hamplard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,9 @@ impl HamplardContract {
panic!("course must be paused before archiving");
}

let mut refund_count = 0u32;
let mut total_refunded = 0i128;

if let Some(ref students) = students_to_refund {
let token_client = token::Client::new(&env, &course.token);
let platform_fee_pct = course.platform_fee_percent as i128;
Expand Down Expand Up @@ -919,7 +922,14 @@ impl HamplardContract {

env.events().publish(
(Symbol::new(&env, "course_archived"), course_id.clone()),
(course_id, admin1, admin2),
(
course_id.clone(),
admin1.clone(),
admin2.clone(),
refund_count,
total_refunded,
env.ledger().sequence(),
),
);
}

Expand Down Expand Up @@ -1234,6 +1244,12 @@ impl HamplardContract {
}
}

if let Some(expiry) = course.expires_at_ledger {
if env.ledger().sequence() >= expiry {
panic!("course has expired");
}
}

if !env
.storage()
.instance()
Expand Down Expand Up @@ -1483,6 +1499,12 @@ impl HamplardContract {
}
}

if let Some(expiry) = course.expires_at_ledger {
if env.ledger().sequence() >= expiry {
panic!("course has expired");
}
}

// Archive the completed enrollment — including its evidence hash
// and certificate_id linkage — before it is overwritten.
let history_key = DataKey::EnrollmentHistory(student.clone(), course_id.clone());
Expand Down Expand Up @@ -1943,7 +1965,14 @@ impl HamplardContract {
Symbol::new(&env, "certificate_revoked"),
certificate_id.clone(),
),
(certificate_id, admin, reason),
(
admin.clone(),
certificate_id.clone(),
cert.student.clone(),
cert.course_id.clone(),
reason.clone(),
env.ledger().sequence(),
),
);
}

Expand Down
88 changes: 88 additions & 0 deletions contracts/hamplard/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,91 @@ fn test_revoke_certificate() {
assert_eq!(cert.revocation_reason, Some(reason));
}

#[test]
fn test_event_certificate_revoked() {
let (env, contract_id, token_id, admin, _sec_admin, _treasury, instructor) = setup();
let client = HamplardContractClient::new(&env, &contract_id);

let student = Address::generate(&env);
token::StellarAssetClient::new(&env, &token_id).mint(&student, &100_000_000_000);

register_and_approve_course(
&env,
&client,
&token_id,
&admin,
&instructor,
"COURSE-EVENT-REVOKE",
300_000_000,
);

let course_id = String::from_str(&env, "COURSE-EVENT-REVOKE");
let cert_id = String::from_str(&env, "CERT-REVOKE-123");

client.enroll(&student, &course_id);
client.mark_completed(
&admin,
&student,
&course_id,
&Some(String::from_str(&env, "proof")),
);
client.issue_certificate(
&admin,
&cert_id,
&course_id,
&String::from_str(&env, "Title"),
&student.to_string(),
&None,
&None,
);

let ledger_before = env.ledger().sequence();
let reason = String::from_str(&env, "ACADEMIC_DISHONESTY");
client.revoke_certificate(&admin, &cert_id, &reason);

// Verify certificate_revoked event was emitted exactly once with correct data
let events = env.events().all();
let mut revoke_events = 0u32;

for (contract, topics, data) in events.iter() {
if contract != contract_id {
continue;
}

let topic0 = topics.get(0).unwrap();
let sym: Symbol = topic0.try_into_val(&env).unwrap();

if sym == Symbol::new(&env, "certificate_revoked") {
revoke_events += 1;

// Verify topic 1 is the certificate_id
let topic1: String = topics.get(1).unwrap().try_into_val(&env).unwrap();
assert_eq!(topic1, cert_id);

// Verify event data: (admin, certificate_id, student, course_id, reason, ledger_sequence)
let (
event_admin,
event_certificate_id,
event_student,
event_course_id,
event_reason,
event_ledger,
): (Address, String, Address, String, String, u32) =
data.try_into_val(&env).unwrap();

assert_eq!(event_admin, admin);
assert_eq!(event_certificate_id, cert_id);
assert_eq!(event_student, student);
assert_eq!(event_course_id, course_id);
assert_eq!(event_reason, reason);
assert!(event_ledger >= ledger_before);
}
}

// Ensure exactly one certificate_revoked event was emitted
assert_eq!(revoke_events, 1);
}

#[test]
fn test_revoke_certificate_metadata_persisted() {
let (env, contract_id, token_id, admin, _sec_admin, _treasury, instructor) = setup();
Expand Down Expand Up @@ -4464,6 +4549,9 @@ fn test_multi_sig_admin_events_record_both_actors() {
.try_into_val(&env)
.unwrap();
assert_eq!(event_course_id, course_id);
assert_eq!(refund_count, 0);
assert_eq!(total_refunded, 0);
assert!(ledger_seq >= 1);
assert!(
(event_admin1 == admin && event_admin2 == sec_admin)
|| (event_admin1 == sec_admin && event_admin2 == admin)
Expand Down