Skip to content
Open
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
95 changes: 95 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Build & Publish Contract Documentation

on:
push:
branches: [main]
paths:
- 'contracts/src/**'
- 'contracts/Cargo.toml'
- '.github/workflows/docs.yml'
pull_request:
branches: [main]
paths:
- 'contracts/src/**'
- 'contracts/Cargo.toml'
- '.github/workflows/docs.yml'
workflow_dispatch:

env:
CARGO_TERM_COLOR: always

jobs:
build-docs:
name: Build Contract Documentation
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32v1-none

- name: Cache Cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
contracts/target/
key: ${{ runner.os }}-cargo-docs-${{ hashFiles('contracts/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-docs-

- name: Build documentation
run: |
cd contracts
cargo doc --no-deps --document-private-items

- name: Run doc tests
run: |
cd contracts
cargo test --doc

- name: Upload doc artifacts
uses: actions/upload-artifact@v4
with:
name: contract-docs
path: contracts/target/wasm32v1-none/doc/
retention-days: 7

publish-docs:
name: Publish to GitHub Pages
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build-docs
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download doc artifacts
uses: actions/download-artifact@v4
with:
name: contract-docs
path: docs-output

- name: Setup Pages
uses: actions/configure-pages@v4

- name: Upload to Pages
uses: actions/upload-pages-artifact@v3
with:
path: docs-output

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
52 changes: 42 additions & 10 deletions contracts/src/courseMetadata.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
//! # Course Metadata Module
//!
//! Course metadata management with packed storage, instructor profiles,
//! completion tracking, and rating system.
//!
//! ## Key Features
//!
//! - **Packed storage**: Course status and flags are bit-packed for gas efficiency.
//! - **Large data hashing**: Prerequisites, objectives, and tags are hashed to
//! minimize on-chain storage; full data is stored in separate keys.
//! - **Instructor profiles**: Separate instructor storage with packed ratings.
//! - **Completion tracking**: Student course completions with grade and skills.
//! - **Ratings**: Packed rating storage with weighted average calculation.

use crate::utils::storage::{PackedRating, PackedTimestamps};
use soroban_sdk::{
contract, contractimpl, contracttype, Address, Env, String, Vec,
Expand Down Expand Up @@ -139,7 +153,8 @@ pub struct UpdateCourseParams {
pub struct CourseMetadataContract;

impl CourseMetadataContract {
/// Initialize the contract with optimized storage
/// Initialize the course metadata contract with the admin address.
/// Resets all counters.
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&CourseMetadataKey::Admin) {
panic!("Contract already initialized");
Expand All @@ -159,7 +174,12 @@ impl CourseMetadataContract {
.set(&CourseMetadataKey::CompletionCount, &0u64);
}

/// Create and store course metadata with optimized storage
/// Create and store course metadata, including prerequisites, learning
/// objectives, and tags. Automatically creates an instructor profile if
/// one doesn't exist.
///
/// # Returns
/// The assigned course ID string.
pub fn create_course_metadata(
env: Env,
instructor: Address,
Expand Down Expand Up @@ -273,7 +293,10 @@ impl CourseMetadataContract {
course_id_str
}

/// Update course metadata
/// Update course metadata fields. Only the course instructor may update.
///
/// # Returns
/// `true` on success.
pub fn update_course(
env: Env,
course_id: String,
Expand Down Expand Up @@ -339,7 +362,10 @@ impl CourseMetadataContract {
true
}

/// Verify course authenticity
/// Verify course authenticity by re-computing the verification hash.
///
/// # Returns
/// `true` if the stored hash matches the re-computed hash.
pub fn verify_course(env: Env, course_id: String) -> bool {
let course_metadata: CourseMetadata = env
.storage()
Expand All @@ -359,15 +385,15 @@ impl CourseMetadataContract {
verification_data == course_metadata.verification_hash
}

/// Get course metadata
/// Get course metadata by course ID.
pub fn get_course(env: Env, course_id: String) -> CourseMetadata {
env.storage()
.instance()
.get(&CourseMetadataKey::Course(course_id))
.unwrap_or_else(|| panic!("Course not found"))
}

/// Get instructor profile
/// Get an instructor profile by address.
pub fn get_instructor_profile(env: Env, instructor: Address) -> InstructorProfile {
env.storage()
.instance()
Expand Down Expand Up @@ -427,7 +453,10 @@ impl CourseMetadataContract {
u64_to_hex_string(hash)
}

/// Record course completion
/// Record a student's course completion with grade and skills acquired.
///
/// # Returns
/// The assigned completion ID string.
pub fn record_completion(
env: Env,
course_id: String,
Expand Down Expand Up @@ -502,23 +531,26 @@ impl CourseMetadataContract {
Vec::new(&env)
}

/// Get total course count
/// Get the total number of courses stored.
pub fn get_course_metadata_count(env: Env) -> u64 {
env.storage()
.instance()
.get(&CourseMetadataKey::CourseCount)
.unwrap_or(0)
}

/// Get total completion count
/// Get the total number of course completions recorded.
pub fn get_completion_count(env: Env) -> u64 {
env.storage()
.instance()
.get(&CourseMetadataKey::CompletionCount)
.unwrap_or(0)
}

/// Rate a course with packed rating storage
/// Rate a course (0-100) with weighted average calculation in packed storage.
///
/// # Returns
/// `true` on success.
pub fn rate_course(env: Env, course_id: String, _rater: Address, rating: u32) -> bool {
if rating > 100 {
panic!("Rating must be between 0 and 100");
Expand Down
75 changes: 62 additions & 13 deletions contracts/src/credential_registry.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
//! # Credential Registry Module
//!
//! Enhanced credential management with expiration support, renewal tracking,
//! batch issuance (issue #118), and attestation count tracking (issue #122).
//!
//! ## Key Features
//!
//! - **Expiration**: Credentials carry a validity duration and are automatically
//! marked expired when queried after their `expires_at` timestamp.
//! - **Renewal**: Expired or active credentials can be renewed by the admin or
//! the credential recipient, with full renewal history stored on-chain.
//! - **Batch issuance**: Up to [`MAX_BATCH_SIZE`] credentials can be issued in
//! a single atomic transaction via [`issue_credentials_batch`].
//! - **Proctored issuance**: Credentials can be linked to proctoring sessions
//! via [`issue_proctored_cred_with_exp`].
//! - **Storage migration**: v1 → v2 migration seeds attestation count markers.
//!
//! ## Storage Layout
//!
//! All credentials are stored in persistent storage for durability. Counters
//! and indices are kept in instance storage for gas efficiency.

use crate::credential_events::{
publish_credential_event, CredentialLifecycleEvent,
};
Expand Down Expand Up @@ -91,7 +113,20 @@ pub enum CredentialEvent {
StatusChanged(u64), // credential_id
}

/// Issue a new credential with expiration support
/// Issue a new credential with an expiration timestamp.
///
/// # Parameters
/// * `env` - Soroban environment.
/// * `issuer` - Must match the stored admin address.
/// * `recipient` - The credential recipient (non-zero address required).
/// * `title` - Credential title.
/// * `description` - Longer description.
/// * `course_id` - Associated course identifier.
/// * `ipfs_hash` - IPFS content hash for off-chain metadata.
/// * `validity_duration` - Seconds from issuance until the credential expires.
///
/// # Returns
/// The newly assigned credential ID.
pub fn issue_credential_with_expiration(
env: &Env,
issuer: Address,
Expand Down Expand Up @@ -179,7 +214,14 @@ pub fn issue_credential_with_expiration(
credential_id
}

/// Renew an existing credential
/// Renew an existing credential, extending its expiration.
///
/// # Parameters
/// * `renewer` - Must be either the admin or the credential recipient.
/// * `extension_duration` - Seconds to add from the current ledger time.
///
/// # Returns
/// `true` on success.
pub fn renew_credential(
env: &Env,
credential_id: u64,
Expand Down Expand Up @@ -266,7 +308,10 @@ pub fn renew_credential(
true
}

/// Check and update credential expiration status
/// Check if a credential has expired and update its status.
///
/// # Returns
/// The credential's current [`CredentialStatus`].
pub fn check_credential_expiration(env: &Env, credential_id: u64) -> CredentialStatus {
StorageVersion::require_compatible_version(env);
let mut credential: CredentialRegistry = env
Expand Down Expand Up @@ -318,7 +363,10 @@ pub fn check_credential_expiration(env: &Env, credential_id: u64) -> CredentialS
credential.status
}

/// Batch update expiration status for multiple credentials
/// Update expiration status for multiple credentials at once.
///
/// # Returns
/// The subset of credential IDs that are now in the Expired state.
pub fn batch_update_expiration_status(env: &Env, credential_ids: Vec<u64>) -> Vec<u64> {
let mut expired_credentials = Vec::new(env);

Expand All @@ -333,7 +381,7 @@ pub fn batch_update_expiration_status(env: &Env, credential_ids: Vec<u64>) -> Ve
expired_credentials
}

/// Get credential with current status
/// Get a credential with its current status (checks expiration first).
pub fn get_credential(env: &Env, credential_id: u64) -> CredentialRegistry {
// Version guard first: refuse reads on unknown layouts (issue #120).
StorageVersion::require_compatible_version(env);
Expand All @@ -346,31 +394,34 @@ pub fn get_credential(env: &Env, credential_id: u64) -> CredentialRegistry {
.unwrap_or_else(|| panic!("Credential not found"))
}

/// Get user credentials with current status
/// Get all credential IDs for a user.
pub fn get_user_credentials(env: &Env, user: Address) -> Vec<u64> {
env.storage()
.persistent()
.get(&CredentialRegistryKey::UserCredentials(user))
.unwrap_or_else(|| Vec::new(env))
}

/// Get expired credentials list
/// Get the list of all expired credential IDs.
pub fn get_expired_credentials(env: &Env) -> Vec<u64> {
env.storage()
.instance()
.get(&CredentialRegistryKey::ExpiredCredentials)
.unwrap_or_else(|| Vec::new(env))
}

/// Get renewal history for a credential
/// Get the full renewal history for a credential.
pub fn get_renewal_history(env: &Env, credential_id: u64) -> Vec<RenewalRecord> {
env.storage()
.instance()
.get(&CredentialRegistryKey::RenewalHistory(credential_id))
.unwrap_or_else(|| Vec::new(env))
}

/// Revoke a credential
/// Revoke a credential. Only the admin may revoke.
///
/// # Returns
/// `true` on success.
pub fn revoke_credential(env: &Env, credential_id: u64, revoker: Address) -> bool {
StorageVersion::require_compatible_version(env);
revoker.require_auth();
Expand Down Expand Up @@ -409,15 +460,15 @@ pub fn revoke_credential(env: &Env, credential_id: u64, revoker: Address) -> boo
true
}

/// Get credential count
/// Get the total number of credentials in the registry.
pub fn get_credential_count(env: &Env) -> u64 {
env.storage()
.instance()
.get(&CredentialRegistryKey::CredentialCount)
.unwrap_or(0)
}

/// Check if a credential is currently valid
/// Whether a credential is in the `Active` state.
pub fn is_credential_valid(env: &Env, credential_id: u64) -> bool {
let credential = get_credential(env, credential_id);
matches!(credential.status, CredentialStatus::Active)
Expand All @@ -430,8 +481,6 @@ pub fn credential_exists(env: &Env, credential_id: u64) -> bool {
.has(&CredentialRegistryKey::Credential(credential_id))
}

// ===== Attestation tracking (issue #122 integration) =====

/// Number of active attestations recorded against a credential.
pub fn get_attestation_count(env: &Env, credential_id: u64) -> u32 {
env.storage()
Expand Down
Loading
Loading