Skip to content
Open
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
346 changes: 250 additions & 96 deletions contracts/course-registry/README.md
Original file line number Diff line number Diff line change
@@ -1,106 +1,290 @@
# Course Registry Contract

A Soroban smart contract for managing courses on the Orivex platform. This contract enables protocol admins to deactivate courses while preserving learner credentials.
A Soroban smart contract managing the full course lifecycle for the Orivex platform — from creation and enrollment through module completion, badge minting, and reward distribution.

## Overview

The Course Registry contract provides functionality to:
- Create new courses with an active status
- Deactivate courses to stop new enrollments
- Reactivate courses when needed
- Retrieve course information
The Course Registry provides functionality to:
- Create and manage courses with metadata
- Enroll learners (individually or in batches)
- Track per-learner module progress
- Mark modules as completed (admin-verified)
- Mint soulbound NFT badges on course completion
- Trigger reward payouts via the Reward Pool
- Support contract upgrades with versioned storage migrations

Deactivated courses remain in storage so past learners keep their credentials, but the state change signals the frontend to hide the course and blocks new enrollments.
## Functions

## Features
### Admin Setup

### Core Functions
#### `initialize(env, admin)`

#### `create_course(env: Env, admin: Address, id: u32, title: Symbol) -> Symbol`
One-time deploy-time setup. Stores the Protocol Admin address in instance storage. Panics with `AlreadyInitialized` if called twice.

Creates a new course in the registry.
**Auth:** `admin.require_auth()` — cryptographic signature required.

**Parameters:**
- `env`: The Soroban environment
- `admin`: The admin address performing the action (must be authenticated)
- `id`: Unique course identifier
- `title`: Course title (Symbol, max 9 characters)
#### `migrate(env, admin)`

**Returns:** `"success"` symbol on successful creation
Applies pending storage-schema migrations after a WASM upgrade. Reads the on-chain version, runs any required migrations, and writes the new `VERSION` constant.

**Events:** Emits `CourseCreated` event with admin and title
**Auth:** Admin-only. Panics with `"Already at current version"` if the schema is already up to date.

**Example:**
```rust
let result = client.create_course(&admin, &1u32, &symbol_short!("Rust101"));
```
#### `contract_version(env) -> u32`

Returns the schema version stored in instance storage (0 = pre-versioning baseline).

#### `set_course_status(env: Env, admin: Address, id: u32, active: bool) -> Symbol`
### Course Management

Updates the active status of a course.
#### `create_course(env, admin, instructor, total_modules, metadata_hash) -> u32`

Allocates a new course with a monotonically-increasing ID.

**Parameters:**
- `env`: The Soroban environment
- `admin`: The admin address performing the action (must be authenticated)
- `id`: Course ID to update
- `active`: New status (true = active, false = deactivated)
- `admin: Address` — Protocol Admin (must authenticate)
- `instructor: Address` — The course instructor/owner
- `total_modules: u32` — Module count; must be > 0 and ≤ `DEFAULT_TOTAL_MODULES_BOUND` (1000)
- `metadata_hash: BytesN<32>` — IPFS CID of course metadata (32-byte hash)

**Returns:** `"success"` symbol on successful update
**Returns:** The newly allocated `u32` course ID.

**Events:** Emits `status` event with admin and status ("active" or "inactive")
**Panics:**
- `"total_modules must be greater than 0"`
- `"total_modules exceeds bound"` (above 1000)
- `"Course ID limit reached"` (u32::MAX courses)
- `"Unauthorized: Caller is not the protocol admin"`

**Panics:** If course does not exist
**Events:** `CourseCreated { id, instructor, total_modules }`

**Example:**
```rust
// Deactivate a course
let result = client.set_course_status(&admin, &1u32, &false);
#### `set_course_status(env, admin, id, active)`

// Reactivate a course
let result = client.set_course_status(&admin, &1u32, &true);
```
Toggles a course's active flag. Inactive courses block new enrollments but preserve existing learner progress.

#### `get_course(env: Env, id: u32) -> (u32, Symbol, bool)`
**Auth:** Protocol Admin only.

Retrieves course information from the registry.
**Panics:** `"Course not found"` if the ID is invalid.

**Parameters:**
- `env`: The Soroban environment
- `id`: Course ID to retrieve
**Events:** `CourseStatusChanged { id, active }`

**Returns:** Tuple of (id, title, active)
#### `get_course(env, id) -> Course`

**Panics:** If course does not exist
Returns the full `Course` struct for the given ID.

**Example:**
```rust
let (id, title, active) = client.get_course(&1u32);
```
**Returns:** `Course { instructor: Address, total_modules: u32, metadata_hash: BytesN<32>, active: bool }`

## Storage
**Panics:** `"Course not found"` if the ID has no record.

The contract uses Soroban's persistent storage with the following key structure:
#### `course_count(env) -> u32`

```
("course", course_id) -> (id: u32, title: Symbol, active: bool)
("active", course_id) -> bool (cached for quick status checks)
```
Returns the total number of registered courses. Returns 0 before the first `create_course` call.

## Events
#### `update_metadata(env, id, new_hash)`

Replaces a course's IPFS metadata hash. Only the current course instructor may call this.

**Auth:** `course.instructor.require_auth()` — current instructor must sign.

**Panics:** `"Course not found"`

**Events:** `MetadataUpdated { id, instructor, new_hash }`

#### `transfer_ownership(env, current_instructor, new_instructor, course_id)`

Transfers course ownership to a new instructor address.

**Auth:** `current_instructor.require_auth()`. Panics with `"Unauthorized: Caller is not the course instructor"` if the caller doesn't match.

**Events:** `OwnershipTransferred { course_id, previous_instructor, new_instructor }`

### Enrollment

#### `enroll(env, learner, id)`

Initializes a learner's progress record to 0 for the given course. The learner must sign the transaction.

**Auth:** `learner.require_auth()`

**Panics:**
- `"Course not found"`
- `"Course is not active"` (the course is deactivated)
- `"Learner already enrolled"` (progress record already exists)

**Storage:** Writes `DataKey::Progress(learner, id) = 0u32`.

#### `enroll_many(env, admin, learners, id) -> Vec<bool>`

Batch-enrolls multiple learners in a single transaction (cohort onboarding). Admin-authorized.

**Auth:** Protocol Admin only.

**Returns:** `Vec<bool>` — `true` for each newly-enrolled learner, `false` for each already-enrolled (skipped) learner. Does not abort on partial failure.

**Panics:**
- `"Course not found"`
- `"Course is not active"`
- `"Unauthorized: Caller is not the protocol admin"`

#### `is_enrolled(env, learner, id) -> bool`

Returns `true` if the learner has a progress record for the course (including completed courses).

#### `unenroll(env, learner, id)`

Removes a learner's progress record from storage, allowing re-enrollment.

**Auth:** `learner.require_auth()`

**Panics:**
- `"Course not found"`
- `"Already completed"` — completed courses cannot be unenrolled (completion is irreversible)
- (Soroban host error if the caller is not the learner)

**Events:** `Unenrolled { learner, course_id }`

### CourseCreated
Emitted when a new course is created.
- Topics: `("created", course_id)`
- Data: `(admin_address, course_title)`
**Storage:** Removes `DataKey::Progress(learner, id)` from persistent storage.

### CourseStatusChanged
Emitted when a course status is updated.
- Topics: `("status", course_id)`
- Data: `(admin_address, status_string)` where status_string is "active" or "inactive"
### Progress & Completion

## Authentication
#### `get_progress(env, learner, id) -> u32`

All functions that modify state (`create_course`, `set_course_status`) should enforce admin authentication at the invocation layer. In production deployments, the `admin.require_auth()` call should be uncommented to enforce authentication within the contract. For testing purposes, authentication is handled externally to allow proper unit testing.
Returns a learner's completed module count. Returns 0 for unenrolled learners (no panic).

#### `is_course_finished(env, learner, id) -> bool`

Returns `true` when the learner's progress ≥ `course.total_modules`. Defensive: excess progress also counts as finished.

**Panics:** `"Course not found"` for invalid course IDs.

#### `complete_module(env, verifier, learner, id)`

Records a verifier-confirmed module completion. On the final module, mints a soulbound badge and triggers reward distribution.

**Auth:** Protocol Admin only (the verifier).

**Panics:**
- `"Course not found"`
- `"Course already completed"` (progress already at total_modules)
- `"NotInitialized"` / `"Unauthorized: Caller is not the protocol admin"`

**Events:**
- `ModuleCompleted { learner, course_id, new_progress }` — every module
- `CourseCompleted { learner, course_id, reward_amount }` — final module only (if reward distributed)

**Cross-contract calls:**
- Mints `BadgeNFT` via the configured badge address (if set)
- Triggers `RewardPool::try_distribute_reward` (if configured)

Follows the checks-effects-interactions pattern. If reward distribution fails, a `PendingReward` record is stored for later retry.

#### `claim_completion_reward(env, learner, course_id)`

Retries a previously-failed reward payout.

**Auth:** `learner.require_auth()`

**Panics:** `"No pending reward for this learner and course"`

### Cross-Contract Wiring

#### `set_reward_pool_address(env, admin, reward_pool_address)`

Registers the RewardPool contract address so course completions can trigger payouts.

**Auth:** Protocol Admin only.

#### `set_badge_nft_address(env, admin, badge_nft_address)`

Registers the BadgeNFT contract address so completed courses can mint soulbound badges.

**Auth:** Protocol Admin only.

### Upgrade

#### `upgrade_contract(env, admin, new_wasm_hash)`

Replaces the contract WASM with the supplied hash on the Soroban host. After swapping, the caller must invoke `migrate()` in a subsequent transaction.

**Auth:** Protocol Admin only.

**Events:** `ContractUpgraded { admin, new_wasm_hash }`

## Events

All events use Soroban's typed `#[contractevent]` with `#[topic]` attributes for efficient indexing.

| Event | Topics | Data |
|-------|--------|------|
| `CourseCreated` | `id`, `instructor` | `total_modules` |
| `CourseStatusChanged` | `id` | `active` |
| `MetadataUpdated` | `id`, `instructor` | `new_hash` |
| `OwnershipTransferred` | `course_id`, `previous_instructor` | `new_instructor` |
| `ModuleCompleted` | `learner`, `course_id` | `new_progress` |
| `CourseCompleted` | `learner`, `course_id` | `reward_amount` |
| `Unenrolled` | `learner`, `course_id` | — |
| `ContractUpgraded` | `admin` | `new_wasm_hash` |

## Storage Layout

### Instance Storage (per-contract singleton)

| Key | Type | Description |
|-----|------|-------------|
| `DataKey::Admin` | `Address` | Protocol Admin address |
| `DataKey::CourseCount` | `u32` | Monotonically-increasing course counter |
| `DataKey::BadgeNftAddress` | `Address` | BadgeNFT contract (optional) |
| `DataKey::RewardPoolAddress` | `Address` | RewardPool contract (optional) |
| `DataKey::Version` | `u32` | Schema version (0 = pre-versioning) |

### Persistent Storage (per-entity)

| Key | Type | Description |
|-----|------|-------------|
| `DataKey::Course(id)` | `Course` | Full course record |
| `DataKey::Progress(learner, course_id)` | `u32` | Learner's completed module count |
| `DataKey::PendingReward(learner, course_id)` | `bool` | Pending reward flag for retry |

## Auth Model

| Function | Caller | Auth Check |
|----------|--------|------------|
| `initialize` | Deployer | `admin.require_auth()` |
| `create_course` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `set_course_status` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `update_metadata` | Course Instructor | `course.instructor.require_auth()` |
| `transfer_ownership` | Current Instructor | `current_instructor.require_auth()` + equality check |
| `enroll` | Learner | `learner.require_auth()` |
| `enroll_many` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `unenroll` | Learner | `learner.require_auth()` |
| `complete_module` | Protocol Admin | `verifier.require_auth()` + stored admin check |
| `claim_completion_reward` | Learner | `learner.require_auth()` |
| `set_reward_pool_address` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `set_badge_nft_address` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `upgrade_contract` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `migrate` | Protocol Admin | `admin.require_auth()` + stored admin check |
| `get_course` | Anyone | None (read-only) |
| `get_progress` | Anyone | None (read-only) |
| `is_enrolled` | Anyone | None (read-only) |
| `is_course_finished` | Anyone | None (read-only) |
| `course_count` | Anyone | None (read-only) |
| `contract_version` | Anyone | None (read-only) |

## Constants

| Constant | Value | Description |
|----------|-------|-------------|
| `VERSION` | `1` | Current storage schema version |
| `INITIAL_COURSE_ID` | `1` | First course ID (1-based) |
| `MAX_COURSE_ID` | `u32::MAX` | Maximum course count |
| `DEFAULT_TOTAL_MODULES_BOUND` | `1000` | Max modules per course |
| `BASE_REWARD_AMOUNT` | `10_0000000` | 10 USDC (7 decimals) |

## Cross-Contract Dependencies

The Course Registry integrates with two sibling contracts:

1. **BadgeNFT** — Called via `BadgeNFTClient::mint_badge` on course completion to mint a soulbound badge for the learner.
2. **RewardPool** — Called via `RewardPoolClient::try_distribute_reward` on course completion to pay out the learner's reward. The Course Registry must be whitelisted via `RewardPool::add_approved_spender` before payouts succeed.

Both addresses are configured via their respective setter functions by the Protocol Admin.

## Building

Expand All @@ -110,36 +294,6 @@ cargo build -p course-registry --release

## Testing

The contract includes comprehensive tests covering:
- Course creation
- Course deactivation
- Course reactivation
- Error handling for non-existent courses

To run tests:
```bash
cargo test -p course-registry --lib
```

## Acceptance Criteria

✅ Active status is toggled in storage
✅ Only the admin address can trigger the change
✅ Deactivated courses remain in storage for credential preservation
✅ Events are emitted for all state changes
✅ Proper error handling for non-existent courses

## Implementation Notes

- Symbol names are limited to 9 characters in Soroban
- Course titles should be kept concise due to Symbol limitations
- The contract uses tuple storage for efficient data retrieval
- Authentication is enforced at the contract level via `require_auth()`

## Future Enhancements

- Add course metadata (description, category, etc.)
- Implement course enrollment tracking
- Add course deletion with proper cleanup
- Support for multiple admin roles
- Course versioning for content updates
Loading