diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0fc2269..b432753 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,8 @@ on:
branches:
- main
- develop
+ tags:
+ - "v*"
pull_request:
types:
- opened
@@ -15,7 +17,7 @@ jobs:
ci:
name: Rust CI
runs-on: ubuntu-latest
-
+
defaults:
run:
working-directory: ./contracts
@@ -49,4 +51,56 @@ jobs:
run: cargo test
- name: Build all Wasm artifacts
- run: stellar contract build
\ No newline at end of file
+ run: stellar contract build
+
+ # Verify that rustdoc produces zero warnings for all crates,
+ # including private items. This enforces the /// migration.
+ - name: Check documentation (zero warnings)
+ run: cargo doc --no-deps --document-private-items
+ env:
+ RUSTDOCFLAGS: "-D warnings"
+
+ docs:
+ name: Publish docs to GitHub Pages
+ runs-on: ubuntu-latest
+ # Only run on version tags (e.g. v1.2.3)
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: ci
+
+ permissions:
+ contents: write # needed to push to gh-pages branch
+
+ defaults:
+ run:
+ working-directory: ./contracts
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo build artifacts
+ uses: Swatinem/rust-cache@v2
+ with:
+ cache-on-failure: true
+ workspaces: "./contracts"
+
+ - name: Build documentation
+ run: cargo doc --no-deps --document-private-items
+ env:
+ RUSTDOCFLAGS: "-D warnings"
+
+ # Add a redirect from the root so GitHub Pages opens the right crate.
+ - name: Add root redirect
+ run: |
+ echo '' \
+ > ../target/doc/index.html
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./target/doc
+ destination_dir: docs
diff --git a/contracts/badge-nft/src/lib.rs b/contracts/badge-nft/src/lib.rs
index 9729294..9007495 100644
--- a/contracts/badge-nft/src/lib.rs
+++ b/contracts/badge-nft/src/lib.rs
@@ -1,62 +1,101 @@
+//! # Badge NFT Contract
+//!
+//! Soulbound badge issuance, retrieval, and admin revocation for the Orivex protocol.
+//!
+//! Each badge is tied to a `(learner, course_id)` pair and is non-transferable
+//! (soulbound). The contract is intentionally minimal: it delegates all
+//! business-logic gates (who may mint) to the stored admin/registry address.
+//!
+//! ## Architecture
+//!
+//! The `#[contractclient]` macro generates [`BadgeNFTClient`] in every build
+//! without emitting WASM exports. The actual struct and `#[contractimpl]` block
+//! are gated behind the `contract` feature flag, which prevents duplicate
+//! symbol errors when this crate is linked as a dependency of another contract
+//! (e.g. `course-registry`).
+//!
+//! ## Storage layout
+//!
+//! | Key | Storage | Type | Description |
+//! |-----|---------|------|-------------|
+//! | `DataKey::Admin` | Instance | `Address` | Authorized minter / registry |
+//! | `DataKey::UserBadges(addr)` | Persistent | `Vec` | All badges for a learner |
+
#![no_std]
+/// Default `minted_at` sentinel used when no ledger timestamp is available.
pub const BADGE_MINTED_AT_DEFAULT: u64 = 0;
-// Operational notes — badge revocation is irreversible from
-// this contract; off-chain records must snapshot
-// `badge.course_id` and `badge.minted_at`. Badge lookups are
-// linear scans; the `MAX_BADGES_PER_LEARNER` constant guards
-// the iteration budget.
+/// Hard cap on the number of badges a single learner address may hold.
+///
+/// Badge lookups are linear scans over the stored `Vec`, so this
+/// constant bounds the worst-case iteration budget to a predictable value.
pub const MAX_BADGES_PER_LEARNER: u32 = 64;
-// Crate overview — soulbound badge issuance, retrieval, and
-// admin revocation. Implements `BadgeNFTInterface` so dependents
-// can call it through a generated client.
+
use soroban_sdk::{contractclient, contractevent, Address, Env, Vec};
pub mod types;
use types::Badge;
-// `#[contractclient]` generates `BadgeNFTClient` in every build (no wasm exports).
-// `#[contractimpl]` on the struct below generates the wasm exports, but only
-// when the `contract` feature is enabled — preventing duplicate symbols when
-// this crate is linked as a dependency of another contract.
+/// Public interface for the BadgeNFT contract.
+///
+/// `#[contractclient]` generates `BadgeNFTClient` from this trait so that
+/// other contracts (e.g. `course-registry`, `governance`) can cross-call
+/// badge operations without importing the concrete implementation.
#[contractclient(name = "BadgeNFTClient")]
pub trait BadgeNFTInterface {
+ /// See [`contract_impl::BadgeNFT::initialize`].
fn initialize(env: Env, admin: Address);
+ /// See [`contract_impl::BadgeNFT::mint_badge`].
fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32);
+ /// See [`contract_impl::BadgeNFT::revoke_badge`].
fn revoke_badge(env: Env, admin: Address, learner: Address, course_id: u32);
+ /// See [`contract_impl::BadgeNFT::get_badges`].
fn get_badges(env: Env, learner: Address) -> Vec;
+ /// See [`contract_impl::BadgeNFT::get_badge_count`].
fn get_badge_count(env: Env, learner: Address) -> u32;
+ /// See [`contract_impl::BadgeNFT::has_badge`].
fn has_badge(env: Env, learner: Address, course_id: u32) -> bool;
}
+/// Emitted after a badge is successfully minted for a learner.
#[contractevent]
pub struct BadgeMinted {
+ /// Learner who received the badge.
#[topic]
pub learner: Address,
+ /// On-chain course identifier the badge represents.
#[topic]
pub course_id: u32,
+ /// Ledger timestamp at the time of minting.
pub minted_at: u64,
}
+/// Emitted after a badge is revoked from a learner.
#[contractevent]
pub struct BadgeRevoked {
+ /// Learner whose badge was revoked.
#[topic]
pub learner: Address,
+ /// Course identifier of the revoked badge.
#[topic]
pub course_id: u32,
}
+/// Emitted after the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: soroban_sdk::BytesN<32>,
}
-// The actual contract struct and implementation are only compiled when building
-// the badge-nft wasm itself (default feature). Dependents disable this feature
-// to avoid duplicate symbol errors at link time.
+/// Concrete BadgeNFT implementation, compiled only when building the WASM artifact.
+///
+/// Dependents disable the `contract` feature to avoid duplicate symbol errors
+/// at link time while still using the generated [`BadgeNFTClient`].
#[cfg(feature = "contract")]
mod contract_impl {
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};
@@ -70,13 +109,27 @@ mod contract_impl {
#[contractimpl]
impl BadgeNFT {
/// Initializes the BadgeNFT contract with the authorized registry address.
- /// Must be called once upon deployment.
+ ///
+ /// Stores `admin` in instance storage under `DataKey::Admin`. This address
+ /// is the only caller permitted to mint and revoke badges. Must be called
+ /// exactly once after deployment.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — The [`Address`] of the authorized registry or protocol admin.
///
/// # Panics
- /// * If contract is already initialized
- /// Bound-checked initializer used by CourseRegistry to deploy
- /// this contract. Subsequent calls panic via the `Already initialized`
- /// guard.
+ ///
+ /// * `"Already initialized"` — if `DataKey::Admin` is already present in
+ /// instance storage (prevents re-initialization).
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let admin = Address::generate(&env);
+ /// client.initialize(&admin);
+ /// // Second call panics with "Already initialized"
+ /// ```
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
@@ -84,17 +137,33 @@ mod contract_impl {
env.storage().instance().set(&DataKey::Admin, &admin);
}
- /// Mints a Soulbound Token (badge) directly to the learner's address.
- /// Only the official protocol registry can trigger this.
+ /// Mints a soulbound badge for `learner` representing completion of `course_id`.
+ ///
+ /// Only the address stored in `DataKey::Admin` (i.e. the protocol registry)
+ /// may call this function. Each `(learner, course_id)` pair is unique — the
+ /// function walks the learner's existing badge vector and panics on a duplicate.
+ ///
+ /// # Arguments
+ ///
+ /// * `caller` — Must equal the stored admin address; authenticated via
+ /// [`Address::require_auth`].
+ /// * `learner` — The learner address to receive the badge.
+ /// * `course_id` — The on-chain identifier of the completed course.
///
/// # Panics
- /// * If caller authentication fails
- /// * If caller is not the authorized registry
- /// * If learner already has a badge for this course_id (duplicate minting)
- /// Mint a soulbound Badge token for an authorized caller. The
- /// function rejects duplicate (learner, course_id) pairs by walking
- /// the learner's badge vector and panicking on match. This enforces
- /// one-badge-per-course invariants.
+ ///
+ /// * `"Contract not initialized"` — if `DataKey::Admin` is absent.
+ /// * `"Unauthorized: Caller is not the authorized registry"` — if `caller ≠ admin`.
+ /// * `"Badge for this course already exists"` — if the learner already holds
+ /// a badge for `course_id`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// // registry is the stored admin address
+ /// client.mint_badge(®istry, &learner, &1u32);
+ /// assert_eq!(client.get_badge_count(&learner), 1);
+ /// ```
pub fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32) {
caller.require_auth();
@@ -137,25 +206,35 @@ mod contract_impl {
.publish(&env);
}
- /// Revokes a Soulbound Token (badge) from a learner's address.
- /// Only the official protocol registry can trigger this for fraud prevention.
+ /// Revokes a previously minted badge from `learner` for `course_id`.
+ ///
+ /// Removes the matching `Badge` entry from the learner's persistent vector.
+ /// If no badge matches the given `course_id`, the function is a **no-op** —
+ /// no event is emitted and no panic occurs.
///
/// # Arguments
- /// * `admin` - The caller address (must be the authorized registry)
- /// * `learner` - The learner address to revoke the badge from
- /// * `course_id` - The course ID of the badge to revoke
+ ///
+ /// * `admin` — Must equal the stored admin address; authenticated via
+ /// [`Address::require_auth`].
+ /// * `learner` — The learner address whose badge is being revoked.
+ /// * `course_id` — The course identifier of the badge to remove.
///
/// # Panics
- /// * If caller authentication fails
- /// * If caller is not the authorized registry
- /// Revoke a previously-minted badge by removing the matching entry
- /// from the learner's `Badge` vector. If the badge is not present,
- /// the function is a no-op (no event emitted, no panic).
+ ///
+ /// * `"Contract not initialized"` — if `DataKey::Admin` is absent.
+ /// * `"Unauthorized: Caller is not the authorized registry"` — if `admin ≠ stored admin`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.mint_badge(®istry, &learner, &2u32);
+ /// assert!(client.has_badge(&learner, &2u32));
+ /// client.revoke_badge(®istry, &learner, &2u32);
+ /// assert!(!client.has_badge(&learner, &2u32));
+ /// ```
pub fn revoke_badge(env: Env, admin: Address, learner: Address, course_id: u32) {
- // 1. admin.require_auth()
admin.require_auth();
- // 2. Fetch 'Admin' (Registry) address from Instance storage. Assert caller == Admin.
let stored_admin: Address = env
.storage()
.instance()
@@ -166,17 +245,14 @@ mod contract_impl {
"Unauthorized: Caller is not the authorized registry"
);
- // 3. Construct DataKey::UserBadges(learner).
let badges_key = DataKey::UserBadges(learner.clone());
- // 4. Fetch existing Vec.
let mut badges: Vec = env
.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env));
- // 5. Find the badge with course_id and remove it.
let mut found = false;
let mut index_to_remove = 0;
for (i, badge) in badges.iter().enumerate() {
@@ -190,22 +266,29 @@ mod contract_impl {
if found {
badges.remove(index_to_remove);
env.storage().persistent().set(&badges_key, &badges);
-
- // 6. Emit BadgeRevoked event.
BadgeRevoked { learner, course_id }.publish(&env);
}
}
- /// Returns all badges for a specific learner.
+ /// Returns all badges currently held by `learner`.
+ ///
+ /// Returns an empty [`Vec`] when the learner has no badges so callers
+ /// can iterate safely without checking length first.
///
/// # Arguments
- /// * `learner` - The learner address
+ ///
+ /// * `learner` — The learner address to query.
///
/// # Returns
- /// Vector of Badge structs. Returns empty vector if learner has no badges.
- /// Returns the entire badge vector for a learner. An empty
- /// vector is returned when the learner has no badges so callers
- /// can iterate safely without checking length.
+ ///
+ /// A `Vec` — possibly empty — of all badges owned by `learner`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let badges = client.get_badges(&learner);
+ /// assert_eq!(badges.len(), 0); // fresh address has no badges
+ /// ```
pub fn get_badges(env: Env, learner: Address) -> Vec {
let badges_key = DataKey::UserBadges(learner);
env.storage()
@@ -214,32 +297,53 @@ mod contract_impl {
.unwrap_or_else(|| Vec::new(&env))
}
- /// Returns the count of badges for a specific learner.
+ /// Returns the total number of badges held by `learner`.
+ ///
+ /// Equivalent to `get_badges(learner).len()` but avoids deserializing
+ /// individual `Badge` fields when only the count is needed.
///
/// # Arguments
- /// * `learner` - The learner address
+ ///
+ /// * `learner` — The learner address to query.
///
/// # Returns
- /// Number of badges the learner owns.
- /// Returns `badges.len()` for a learner, computing the count
- /// via the canonical `Vec::len` path. Equivalent to iterating
- /// `get_badges` and counting, but cheaper for the hot path.
+ ///
+ /// The `u32` count of badges owned by `learner`. Returns `0` for an
+ /// address that has never received a badge.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert_eq!(client.get_badge_count(&learner), 0);
+ /// client.mint_badge(®istry, &learner, &3u32);
+ /// assert_eq!(client.get_badge_count(&learner), 1);
+ /// ```
pub fn get_badge_count(env: Env, learner: Address) -> u32 {
let badges = Self::get_badges(env, learner);
badges.len()
}
- /// Checks if a learner has a specific badge.
+ /// Returns `true` if `learner` currently holds a badge for `course_id`.
+ ///
+ /// Performs a linear scan over the learner's badge vector. The scan is
+ /// bounded by [`MAX_BADGES_PER_LEARNER`] in practice.
///
/// # Arguments
- /// * `learner` - The learner address
- /// * `course_id` - The course ID to check
+ ///
+ /// * `learner` — The learner address to check.
+ /// * `course_id` — The course identifier to look up.
///
/// # Returns
- /// true if the learner has the badge, false otherwise.
- /// Returns true when the learner already holds a badge for the
- /// given `course_id`. The check is a linear scan over the
- /// learner's badge vector; bounded by `MAX_BADGES_PER_LEARNER`.
+ ///
+ /// `true` if a matching badge exists, `false` otherwise.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert!(!client.has_badge(&learner, &5u32));
+ /// client.mint_badge(®istry, &learner, &5u32);
+ /// assert!(client.has_badge(&learner, &5u32));
+ /// ```
pub fn has_badge(env: Env, learner: Address, course_id: u32) -> bool {
let badges = Self::get_badges(env, learner);
for badge in badges.iter() {
@@ -250,10 +354,22 @@ mod contract_impl {
false
}
- /// Upgrades the contract WASM. Only callable by the Protocol Admin.
- /// Replaces the BadgeNFT WASM with the supplied hash on the
- /// Soroban host. Admin-only. Emits `ContractUpgraded` on
- /// success; panics with `"Unauthorized"` for non-admins.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the BadgeNFT WASM on the Soroban host using
+ /// `env.deployer().update_current_contract_wasm`. Emits [`ContractUpgraded`]
+ /// on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored admin address; authenticated via
+ /// [`Address::require_auth`].
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
@@ -276,7 +392,7 @@ mod contract_impl {
}
}
-// Re-export the struct so tests can use `badge_nft::BadgeNFT` for registration.
+/// Re-export the concrete struct so tests can use `badge_nft::BadgeNFT` for registration.
#[cfg(feature = "contract")]
pub use contract_impl::BadgeNFT;
diff --git a/contracts/badge-nft/src/types.rs b/contracts/badge-nft/src/types.rs
index 484c526..4aba992 100644
--- a/contracts/badge-nft/src/types.rs
+++ b/contracts/badge-nft/src/types.rs
@@ -1,15 +1,41 @@
+//! Shared types for the BadgeNFT contract.
+
use soroban_sdk::{contracttype, Address};
+/// An individual soulbound badge awarded to a learner upon course completion.
+///
+/// Each `Badge` is stored inside the learner's `Vec` under
+/// `DataKey::UserBadges(learner_address)` in persistent storage.
+///
+/// # Invariants
+///
+/// * `(learner_address, course_id)` pairs are unique — the `mint_badge`
+/// function enforces this before insertion.
+/// * `minted_at` is the Soroban ledger timestamp at the moment of minting.
+/// A value of `0` is used only in tests or when the ledger timestamp is
+/// unavailable (see [`BADGE_MINTED_AT_DEFAULT`](crate::BADGE_MINTED_AT_DEFAULT)).
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Badge {
+ /// On-chain identifier of the course this badge represents.
pub course_id: u32,
+ /// Ledger timestamp (Unix seconds) at the time the badge was minted.
pub minted_at: u64,
}
+/// Storage keys used by the BadgeNFT contract.
+///
+/// Variants map to specific Soroban storage tiers:
+///
+/// | Variant | Storage tier | Notes |
+/// |---------|-------------|-------|
+/// | `Admin` | Instance | Single authorized minter address |
+/// | `UserBadges(Address)` | Persistent | Badge vector keyed by learner |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Stores the authorized registry / admin [`Address`].
Admin,
+ /// Stores the `Vec` for the given learner [`Address`].
UserBadges(Address),
}
diff --git a/contracts/course-registry/src/lib.rs b/contracts/course-registry/src/lib.rs
index df6ca55..f9ef395 100644
--- a/contracts/course-registry/src/lib.rs
+++ b/contracts/course-registry/src/lib.rs
@@ -1,20 +1,53 @@
+//! # Course Registry Contract
+//!
+//! Manages the full lifecycle of on-chain courses for the Orivex protocol:
+//! creation, metadata updates, enrollment, progress tracking, and completion.
+//!
+//! On final-module completion the registry performs two optional cross-contract
+//! calls (when those addresses have been wired by the admin):
+//!
+//! 1. **BadgeNFT** — mints a soulbound badge for the learner.
+//! 2. **RewardPool** — distributes a USDC payout to the learner.
+//!
+//! ## Architecture
+//!
+//! A single `Protocol Admin` address gates all privileged mutations (course
+//! creation, status changes, and module verification). Instructors control
+//! only their own course's metadata and ownership.
+//!
+//! ## Storage layout
+//!
+//! | Key | Tier | Type | Description |
+//! |-----|------|------|-------------|
+//! | `DataKey::Admin` | Instance | `Address` | Protocol admin |
+//! | `DataKey::CourseCount` | Instance | `u32` | ID counter |
+//! | `DataKey::BadgeNftAddress` | Instance | `Address` | Wired BadgeNFT |
+//! | `DataKey::RewardPoolAddress` | Instance | `Address` | Wired RewardPool |
+//! | `DataKey::Course(id)` | Persistent | [`Course`] | Course struct |
+//! | `DataKey::Progress(addr, id)` | Persistent | `u32` | Modules completed |
+
#![no_std]
+/// The first course ID allocated by [`CourseRegistry::create_course`].
+///
+/// Course IDs start at 1; 0 is reserved as a sentinel for "no course".
pub const INITIAL_COURSE_ID: u32 = 1;
+/// Upper bound on the course ID space — the full `u32` range.
pub const MAX_COURSE_ID: u32 = u32::MAX;
-// Operational notes — storage costs are amortised over a
-// single `Course` struct per ID and a separate progress
-// record per (learner, course). Roster growth is bounded by
-// Soroban's `u32` ID space and the 7-byte constraint for
-// metadata references.
+/// Practical upper bound on `total_modules` per course.
+///
+/// Soroban storage cost is amortised over a single [`Course`] struct per ID,
+/// but extremely large `total_modules` values would make the `Progress` counter
+/// meaningless. This constant is advisory; enforcement is left to the admin.
pub const DEFAULT_TOTAL_MODULES_BOUND: u32 = 1000;
+/// Base USDC reward amount distributed on course completion (7 decimal places).
+///
+/// Equals 10 USDC when the reward token uses 7 decimal places (Stellar standard).
pub const BASE_REWARD_AMOUNT: i128 = 10_0000000;
-// Crate overview — manages the lifecycle of on-chain courses,
-// their progress records, course completion mint of soulbound
-// badges, and RewardPool payout triggering.
+
use soroban_sdk::{contract, contractevent, contractimpl, Address, BytesN, Env};
pub mod types;
@@ -26,73 +59,115 @@ use reward_pool::RewardPoolClient;
#[contract]
pub struct CourseRegistry;
+/// Emitted when a course instructor updates the IPFS metadata hash.
#[contractevent]
pub struct MetadataUpdated {
+ /// ID of the course whose metadata was updated.
#[topic]
pub id: u32,
+ /// Instructor who authorized the update.
#[topic]
pub instructor: Address,
+ /// New 32-byte IPFS metadata hash.
pub new_hash: BytesN<32>,
}
+/// Emitted when a new course is registered on-chain.
#[contractevent]
pub struct CourseCreated {
+ /// Auto-assigned ID for the new course.
#[topic]
pub id: u32,
+ /// Instructor address associated with the course.
#[topic]
pub instructor: Address,
+ /// Number of modules that must be completed.
pub total_modules: u32,
}
+/// Emitted when a course's active flag is toggled.
#[contractevent]
pub struct CourseStatusChanged {
+ /// ID of the affected course.
#[topic]
pub id: u32,
+ /// New active status (`true` = active, `false` = inactive).
pub active: bool,
}
+/// Emitted when a course is transferred to a new instructor.
#[contractevent]
pub struct OwnershipTransferred {
+ /// ID of the transferred course.
#[topic]
pub course_id: u32,
+ /// Previous instructor address.
#[topic]
pub previous_instructor: Address,
+ /// New instructor address.
pub new_instructor: Address,
}
+/// Emitted each time a learner completes a module.
#[contractevent]
pub struct ModuleCompleted {
+ /// Learner who completed the module.
#[topic]
pub learner: Address,
+ /// Course in which the module was completed.
#[topic]
pub course_id: u32,
+ /// Updated total module-completion count after this call.
pub new_progress: u32,
}
+/// Emitted when a learner completes the final module and receives a reward.
#[contractevent]
pub struct CourseCompleted {
+ /// Learner who finished the course.
#[topic]
pub learner: Address,
+ /// Completed course ID.
#[topic]
pub course_id: u32,
+ /// USDC reward amount distributed (in token decimals).
pub reward_amount: i128,
}
+/// Emitted when the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: BytesN<32>,
}
#[contractimpl]
impl CourseRegistry {
- /// Sets the official Protocol Admin. Must be called once upon deployment.
- /// Sets the single Protocol Admin in instance storage at deploy time.
- /// Idempotent guards prevent re-initialization: the function panics if
- /// `DataKey::Admin` is already present. No auth check is performed
- /// here on purpose — `initialize` is intended to be called only once
- /// by the deployer.
+ /// Initializes the CourseRegistry with the protocol admin address.
+ ///
+ /// Stores `admin` in instance storage under `DataKey::Admin`. This is the
+ /// only call that does **not** require prior admin authentication — it is
+ /// intended to be invoked exactly once by the deployer immediately after
+ /// contract deployment.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Address to record as the protocol admin.
+ ///
+ /// # Panics
+ ///
+ /// * `"Already initialized"` — if `DataKey::Admin` is already set.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let admin = Address::generate(&env);
+ /// client.initialize(&admin);
+ /// // A second call panics with "Already initialized"
+ /// ```
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
@@ -100,13 +175,23 @@ impl CourseRegistry {
env.storage().instance().set(&DataKey::Admin, &admin);
}
- /// Registers the RewardPool contract address so the registry can trigger payouts on completion.
- /// Only callable by the Protocol Admin.
- /// Wires the RewardPool contract address used by `complete_module`.
- /// Only the Protocol Admin may call this; otherwise the call panics
- /// with `"Unauthorized: Caller is not the protocol admin"`. The
- /// RewardPool must additionally whitelist the CourseRegistry via
- /// `add_approved_spender` before payouts will execute.
+ /// Registers the RewardPool contract address for completion payouts.
+ ///
+ /// Wires the RewardPool so that `complete_module` can trigger a USDC payout
+ /// when a learner finishes the final module. The RewardPool must separately
+ /// whitelist the CourseRegistry via `add_approved_spender` before payouts
+ /// will execute.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `reward_pool_address` — Address of the deployed RewardPool contract.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract not initialized"` — if the registry has not been initialized.
+ /// * `"Unauthorized: Caller is not the protocol admin"` — if `admin ≠ stored admin`.
pub fn set_reward_pool_address(env: Env, admin: Address, reward_pool_address: Address) {
admin.require_auth();
@@ -125,12 +210,21 @@ impl CourseRegistry {
.set(&DataKey::RewardPoolAddress, &reward_pool_address);
}
- /// Registers the BadgeNFT contract address so the registry can mint badges on completion.
- /// Only callable by the Protocol Admin.
- /// Wires the BadgeNFT contract address so completed courses can mint
- /// soulbound badges for learners. Admin-only — fails with
- /// `"Unauthorized: Caller is not the protocol admin"` if the caller
- /// isn't the configured Protocol Admin.
+ /// Registers the BadgeNFT contract address for completion badge minting.
+ ///
+ /// Wires the BadgeNFT so that `complete_module` can mint a soulbound badge
+ /// when a learner finishes the final module.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `badge_nft_address` — Address of the deployed BadgeNFT contract.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract not initialized"` — if the registry has not been initialized.
+ /// * `"Unauthorized: Caller is not the protocol admin"` — if `admin ≠ stored admin`.
pub fn set_badge_nft_address(env: Env, admin: Address, badge_nft_address: Address) {
admin.require_auth();
@@ -149,11 +243,38 @@ impl CourseRegistry {
.set(&DataKey::BadgeNftAddress, &badge_nft_address);
}
- /// Registers a new course on-chain.
- /// Allocates the next monotonically-increasing course ID and stores
- /// the resulting `Course` struct in persistent storage under
- /// `DataKey::Course(id)`. `total_modules` must be strictly positive;
- /// the cap on courses is bounded by the `u32` return value.
+ /// Registers a new course on-chain and returns its auto-assigned ID.
+ ///
+ /// Allocates the next monotonically-increasing course ID, constructs a
+ /// [`Course`] struct, and stores it in persistent storage under
+ /// `DataKey::Course(id)`. Emits [`CourseCreated`].
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `instructor` — Address of the course instructor who will own metadata
+ /// updates and ownership transfers.
+ /// * `total_modules` — Number of modules in the course; must be `> 0`.
+ /// * `metadata_hash` — 32-byte IPFS CID hash for the course descriptor.
+ ///
+ /// # Returns
+ ///
+ /// The newly assigned `u32` course ID (starts at 1, increments by 1).
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract not initialized"` — if the registry has not been initialized.
+ /// * `"Unauthorized: Caller is not the protocol admin"` — if `admin ≠ stored admin`.
+ /// * `"total_modules must be greater than 0"` — if `total_modules == 0`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let hash = BytesN::from_array(&env, &[0u8; 32]);
+ /// let id = client.create_course(&admin, &instructor, &5u32, &hash);
+ /// assert_eq!(id, 1u32); // first course gets ID 1
+ /// ```
pub fn create_course(
env: Env,
admin: Address,
@@ -203,11 +324,28 @@ impl CourseRegistry {
new_id
}
- /// Updates the IPFS metadata hash for a course. Only callable by the course instructor.
- /// Replaces a course's IPFS metadata hash with the supplied value.
- /// Only the current instructor is permitted to update; the function
- /// uses `course.instructor.require_auth()` for that check. The new
- /// hash must be a 32-byte BytesN pointing at IPFS CID metadata.
+ /// Updates the IPFS metadata hash for a course.
+ ///
+ /// Only the current instructor of the course may call this function.
+ /// Authentication is performed via `course.instructor.require_auth()`.
+ ///
+ /// # Arguments
+ ///
+ /// * `id` — ID of the course to update.
+ /// * `new_hash` — Replacement 32-byte IPFS CID hash.
+ ///
+ /// # Panics
+ ///
+ /// * `"Course not found"` — if no course with `id` exists.
+ /// * Soroban auth failure if the transaction signer is not the instructor.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let new_hash = BytesN::from_array(&env, &[1u8; 32]);
+ /// client.update_metadata(&course_id, &new_hash);
+ /// assert_eq!(client.get_course(&course_id).metadata_hash, new_hash);
+ /// ```
pub fn update_metadata(env: Env, id: u32, new_hash: BytesN<32>) {
let mut course: Course = env
.storage()
@@ -232,11 +370,30 @@ impl CourseRegistry {
.publish(&env);
}
- /// Enrolls a learner in an active course, initializing their progress to 0.
- /// Initializes a learner progress record at zero for the requested
- /// course. The first enrollment writes `0u32` to the
- /// `DataKey::Progress(learner, id)` slot. Panics with
- /// `"Learner already enrolled"` if a record already exists.
+ /// Enrolls a learner in an active course, initializing their progress to zero.
+ ///
+ /// Writes `0u32` to `DataKey::Progress(learner, id)` in persistent storage.
+ /// The learner must authorize the call. Enrolling in an inactive course or
+ /// re-enrolling in a course the learner already joined both panic.
+ ///
+ /// # Arguments
+ ///
+ /// * `learner` — Address of the learner to enroll; authenticated via
+ /// [`Address::require_auth`].
+ /// * `id` — ID of the course to enroll in.
+ ///
+ /// # Panics
+ ///
+ /// * `"Course not found"` — if no course with `id` exists.
+ /// * `"Course is not active"` — if `course.active == false`.
+ /// * `"Learner already enrolled"` — if a progress record already exists.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.enroll(&learner, &course_id);
+ /// assert_eq!(client.get_progress(&learner, &course_id), 0);
+ /// ```
pub fn enroll(env: Env, learner: Address, id: u32) {
learner.require_auth();
@@ -257,11 +414,23 @@ impl CourseRegistry {
env.storage().persistent().set(&progress_key, &0u32);
}
- /// Helper to check the current total number of courses.
- /// Returns the total number of courses currently registered
- /// on-chain. Reads from `DataKey::CourseCount` instance storage
- /// and defaults to 0 if absent (e.g. before the first
- /// `create_course` call).
+ /// Returns the total number of courses currently registered on-chain.
+ ///
+ /// Reads `DataKey::CourseCount` from instance storage. Returns `0` before
+ /// the first `create_course` call.
+ ///
+ /// # Returns
+ ///
+ /// `u32` count of courses. The highest valid course ID equals this value
+ /// (IDs are 1-based and contiguous).
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert_eq!(client.course_count(), 0);
+ /// client.create_course(&admin, &instructor, &3u32, &hash);
+ /// assert_eq!(client.course_count(), 1);
+ /// ```
pub fn course_count(env: Env) -> u32 {
env.storage()
.instance()
@@ -269,16 +438,27 @@ impl CourseRegistry {
.unwrap_or(0)
}
- /// Toggles a course's active status. Only callable by the Protocol Admin.
- /// Toggles the active flag on the target course and emits
- /// `CourseStatusChanged { id, active }`. Admin-only. The status
- /// change is persisted in `DataKey::Course(id)` and the course
- /// remains in storage so prior learner progress is preserved.
+ /// Toggles a course's active status. Only callable by the protocol admin.
+ ///
+ /// Persists the updated [`Course`] struct and emits [`CourseStatusChanged`].
+ /// Deactivating a course preserves all existing learner progress records;
+ /// it only prevents new enrollments.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `id` — ID of the course to toggle.
+ /// * `active` — New active status.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract not initialized"` — if the registry has not been initialized.
+ /// * `"Unauthorized: Caller is not the protocol admin"` — if `admin ≠ stored admin`.
+ /// * `"Course not found"` — if no course with `id` exists.
pub fn set_course_status(env: Env, admin: Address, id: u32, active: bool) {
- // 1. Authenticate the admin cryptographically
admin.require_auth();
- // 2. Verify caller is the officially registered Protocol Admin
let stored_admin: Address = env
.storage()
.instance()
@@ -289,27 +469,46 @@ impl CourseRegistry {
"Unauthorized: Caller is not the protocol admin"
);
- // 3. Retrieve the course using the CORRECT DataKey
let mut course: Course = env
.storage()
.persistent()
.get(&DataKey::Course(id))
.expect("Course not found");
- // 4. Update the active status and save it
course.active = active;
env.storage()
.persistent()
.set(&DataKey::Course(id), &course);
- // 5. Emit the standard event
CourseStatusChanged { id, active }.publish(&env);
}
- /// Returns true if the learner has completed all modules in the course.
- /// Returns true when the learner's stored progress is at least
- /// `course.total_modules`. The check is defensive — progress
- /// values exceeding total_modules also count as finished.
+ /// Returns `true` if the learner has completed all modules in the course.
+ ///
+ /// The check is intentionally defensive: progress values that exceed
+ /// `total_modules` (which should never occur under normal operation) also
+ /// return `true`.
+ ///
+ /// # Arguments
+ ///
+ /// * `learner` — The learner address to check.
+ /// * `id` — The course ID to check against.
+ ///
+ /// # Returns
+ ///
+ /// `true` when `progress >= course.total_modules`, `false` otherwise.
+ ///
+ /// # Panics
+ ///
+ /// * `"Course not found"` — if no course with `id` exists.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert!(!client.is_course_finished(&learner, &course_id));
+ /// // after all complete_module calls...
+ /// assert!(client.is_course_finished(&learner, &course_id));
+ /// ```
pub fn is_course_finished(env: Env, learner: Address, id: u32) -> bool {
let course: Course = env
.storage()
@@ -326,45 +525,78 @@ impl CourseRegistry {
progress >= course.total_modules
}
- /// Returns the full details of a specific course.
+ /// Returns the full [`Course`] struct for the given ID.
///
/// # Arguments
- /// * `env` - The Soroban environment
- /// * `id` - The course ID
+ ///
+ /// * `id` — The course ID to look up.
///
/// # Returns
- /// The Course struct if found
+ ///
+ /// The [`Course`] struct stored under `DataKey::Course(id)`.
///
/// # Panics
- /// Panics if the course ID is invalid (course doesn't exist in storage)
- /// Reads a Course struct from persistent storage by ID. The
- /// function panics with `"Course not found"` when the ID has
- /// no record, which is the deliberate failure mode for an
- /// out-of-bounds lookup.
+ ///
+ /// * `"Course not found"` — if `id` has no corresponding record in storage.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let course = client.get_course(&1u32);
+ /// assert_eq!(course.total_modules, 5u32);
+ /// ```
pub fn get_course(env: Env, id: u32) -> Course {
- // 1. Construct DataKey::Course(id)
- let key = DataKey::Course(id);
-
- // 2. Fetch Course struct from Persistent storage
- // 3. Assert course exists (panic if not found)
env.storage()
.persistent()
- .get(&key)
+ .get(&DataKey::Course(id))
.expect("Course not found")
}
- /// Returns a learner's completed module count for a course. Returns 0 if the learner has not enrolled.
- /// Reads a learner's current module-completion count for a
- /// course. Returns 0 when the learner has not enrolled (no
- /// matching `DataKey::Progress` slot), avoiding the need to
- /// explicitly call `enroll`.
+ /// Returns a learner's completed module count for a course.
+ ///
+ /// Returns `0` when the learner has not enrolled (i.e. no matching
+ /// `DataKey::Progress` slot), so callers do not need to call `enroll`
+ /// before reading progress.
+ ///
+ /// # Arguments
+ ///
+ /// * `learner` — The learner address to query.
+ /// * `id` — The course ID.
+ ///
+ /// # Returns
+ ///
+ /// `u32` modules completed; `0` if the learner has no progress record.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert_eq!(client.get_progress(&learner, &course_id), 0);
+ /// ```
pub fn get_progress(env: Env, learner: Address, id: u32) -> u32 {
- let key = DataKey::Progress(learner, id);
- env.storage().persistent().get(&key).unwrap_or(0)
+ env.storage()
+ .persistent()
+ .get(&DataKey::Progress(learner, id))
+ .unwrap_or(0)
}
/// Transfers ownership of a course to a new instructor address.
- /// Only callable by the current instructor of the course.
+ ///
+ /// Only the **current** instructor may call this. Authentication is
+ /// verified by checking `course.instructor == current_instructor` before
+ /// calling `current_instructor.require_auth()`. Emits [`OwnershipTransferred`].
+ ///
+ /// # Arguments
+ ///
+ /// * `current_instructor` — Must equal `course.instructor`; authenticated via
+ /// [`Address::require_auth`].
+ /// * `new_instructor` — Address to hand ownership to.
+ /// * `course_id` — ID of the course being transferred.
+ ///
+ /// # Panics
+ ///
+ /// * `"Course not found"` — if no course with `course_id` exists.
+ /// * `"Unauthorized: Caller is not the course instructor"` — if
+ /// `current_instructor ≠ course.instructor`.
pub fn transfer_ownership(
env: Env,
current_instructor: Address,
@@ -397,17 +629,40 @@ impl CourseRegistry {
.publish(&env);
}
- /// Records a learner's completion of a module after off-chain quiz validation.
- /// Only callable by the authorized verifier (protocol admin).
- /// Records a verifier-confirmed module completion and emits the
- /// `ModuleCompleted` event. On the final module, this function
- /// additionally cross-calls the BadgeNFT (mint soulbound badge) and
- /// the RewardPool (USDC payout) when those addresses are wired.
+ /// Records a verifier-confirmed module completion for a learner.
+ ///
+ /// This is the core progression function. It increments the learner's
+ /// module-completion counter and, on the **final** module, triggers two
+ /// optional cross-contract calls (when addresses are wired):
+ ///
+ /// 1. `BadgeNFTClient::mint_badge` — mints a soulbound badge.
+ /// 2. `RewardPoolClient::distribute_reward` — distributes [`BASE_REWARD_AMOUNT`]
+ /// USDC to the learner and emits [`CourseCompleted`].
+ ///
+ /// # Arguments
+ ///
+ /// * `verifier` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `learner` — Address of the learner who completed the module.
+ /// * `id` — Course ID in which the module was completed.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract not initialized"` — if the registry has not been initialized.
+ /// * `"Unauthorized: Caller is not the protocol admin"` — if `verifier ≠ stored admin`.
+ /// * `"Course not found"` — if no course with `id` exists.
+ /// * `"Course already completed"` — if `progress >= total_modules` before this call.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// // Assuming a 1-module course and the learner is enrolled
+ /// client.complete_module(&admin, &learner, &course_id);
+ /// assert!(client.is_course_finished(&learner, &course_id));
+ /// ```
pub fn complete_module(env: Env, verifier: Address, learner: Address, id: u32) {
- // 1. Authenticate the verifier's signature
verifier.require_auth();
- // 2. Verify the verifier is the authorized protocol admin
let stored_admin: Address = env
.storage()
.instance()
@@ -418,35 +673,29 @@ impl CourseRegistry {
"Unauthorized: Caller is not the protocol admin"
);
- // 3. Retrieve the course to validate it exists and get total_modules
let course: Course = env
.storage()
.persistent()
.get(&DataKey::Course(id))
.expect("Course not found");
- // 4. Retrieve current progress (defaults to 0 if not set)
let current_progress: u32 = env
.storage()
.persistent()
.get(&DataKey::Progress(learner.clone(), id))
.unwrap_or(0);
- // 5. Assert current progress is less than total_modules
assert!(
current_progress < course.total_modules,
"Course already completed"
);
- // 6. Increment progress by 1
let new_progress = current_progress + 1;
- // 7. Save new progress to persistent storage
env.storage()
.persistent()
.set(&DataKey::Progress(learner.clone(), id), &new_progress);
- // 8. Emit ModuleCompleted event
ModuleCompleted {
learner: learner.clone(),
course_id: id,
@@ -454,7 +703,7 @@ impl CourseRegistry {
}
.publish(&env);
- // 9. If the learner just finished the final module, mint their soulbound badge
+ // On the final module: mint badge and distribute reward (both optional).
if new_progress == course.total_modules {
if let Some(badge_nft_address) = env
.storage()
@@ -465,7 +714,6 @@ impl CourseRegistry {
badge_nft.mint_badge(&env.current_contract_address(), &learner, &id);
}
- // 10. Trigger reward distribution if RewardPool address is configured
if let Some(reward_pool_address) = env
.storage()
.instance()
@@ -489,11 +737,21 @@ impl CourseRegistry {
}
}
- /// Upgrades the contract WASM. Only callable by the Protocol Admin.
- /// Replaces the contract WASM with the supplied hash on the
- /// Soroban host. Admin-only — non-admins panic with
- /// `"Unauthorized"`. The `ContractUpgraded` event is published
- /// with both the admin's address and the new WASM hash.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the CourseRegistry WASM on the Soroban host. Emits
+ /// [`ContractUpgraded`] on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored protocol admin; authenticated via
+ /// [`Address::require_auth`].
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
diff --git a/contracts/course-registry/src/types.rs b/contracts/course-registry/src/types.rs
index b65660c..b6f84a7 100644
--- a/contracts/course-registry/src/types.rs
+++ b/contracts/course-registry/src/types.rs
@@ -1,21 +1,57 @@
+//! Shared types for the CourseRegistry contract.
+
use soroban_sdk::{contracttype, Address, BytesN};
+/// On-chain representation of a course registered in the protocol.
+///
+/// Stored in persistent storage under `DataKey::Course(id)`.
+///
+/// # Field notes
+///
+/// * `instructor` is the address that controls metadata updates and
+/// ownership transfers for this course.
+/// * `total_modules` must be `> 0` — enforced at creation time.
+/// * `metadata_hash` is a 32-byte hash pointing at IPFS CID metadata
+/// (title, description, syllabus, etc.).
+/// * `active` gates enrollment; a deactivated course rejects new learners
+/// but preserves all existing progress records.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Course {
+ /// Address of the course instructor / owner.
pub instructor: Address,
+ /// Total number of modules that must be completed to finish the course.
pub total_modules: u32,
+ /// IPFS metadata hash for the course content descriptor.
pub metadata_hash: BytesN<32>,
+ /// Whether the course is accepting new enrollments.
pub active: bool,
}
+/// Storage keys used by the CourseRegistry contract.
+///
+/// | Variant | Storage tier | Type | Description |
+/// |---------|-------------|------|-------------|
+/// | `Course(u32)` | Persistent | [`Course`] | Course struct keyed by ID |
+/// | `Progress(Address, u32)` | Persistent | `u32` | Modules completed by a learner |
+/// | `CourseCount` | Instance | `u32` | Monotonically-increasing ID counter |
+/// | `Admin` | Instance | `Address` | Protocol admin address |
+/// | `BadgeNftAddress` | Instance | `Address` | Wired BadgeNFT contract |
+/// | `RewardPoolAddress` | Instance | `Address` | Wired RewardPool contract |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Persistent storage key for a [`Course`] identified by `u32` ID.
Course(u32),
+ /// Persistent storage key for a learner's module-completion count
+ /// for a specific course: `(learner_address, course_id)`.
Progress(Address, u32),
+ /// Instance storage key tracking the highest allocated course ID.
CourseCount,
+ /// Instance storage key for the protocol admin [`Address`].
Admin,
+ /// Instance storage key for the BadgeNFT contract [`Address`].
BadgeNftAddress,
+ /// Instance storage key for the RewardPool contract [`Address`].
RewardPoolAddress,
}
diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs
index a0199b0..13c90ea 100644
--- a/contracts/governance/src/lib.rs
+++ b/contracts/governance/src/lib.rs
@@ -1,18 +1,36 @@
+//! # Governance Contract
+//!
+//! Badge-weighted proposal lifecycle for the Orivex protocol:
+//! create → vote → execute | cancel.
+//!
+//! ## Operational notes
+//!
+//! * Vote weight equals the number of badges a voter holds **at the moment
+//! of casting** — not at proposal creation time.
+//! * Cancellation sets `proposal.executed = true` (the canonical "locked"
+//! state), reusing the executed flag to block further state changes.
+//! * Tied votes (`votes_for == votes_against`) are treated as rejected.
+//!
+//! ## Storage layout
+//!
+//! | Key | Tier | Type | Description |
+//! |-----|------|------|-------------|
+//! | `DataKey::Admin` | Instance | `Address` | Protocol admin |
+//! | `BADGE_NFT_KEY` | Instance | `Address` | Wired BadgeNFT contract |
+//! | `DataKey::Proposal(id)` | Persistent | [`Proposal`] | Proposal record |
+//! | `DataKey::UserVote(addr, id)` | Persistent | `bool` | Double-vote guard |
+
#![no_std]
+/// Starting value for the internal proposal ID counter.
pub const PROPOSAL_COUNTER_START: u32 = 0;
-// Operational notes — proposals progress through: created →
-// voting → (executed | cancelled). Cancellation locks the
-// proposal via `executed = true`. Vote weight is fetched at
-// call time so badge holdings at the moment of the cast
-// determine the weight.
+/// Quorum threshold in basis points (3300 bp = 33 %).
+/// Currently informational; enforcement can be added to `execute_proposal`.
pub const QUORUM_BASIS_POINTS: u32 = 3300;
+/// Default voting period in seconds (7 days = 604 800 s).
pub const DEFAULT_VOTING_PERIOD_SECONDS: u64 = 604800;
-// Crate overview — badge-weighted proposal lifecycle: create,
-// vote, execute, cancel. Vote weight = number of badges owned at
-// the moment of the cast.
use soroban_sdk::{
contract, contractclient, contractevent, contractimpl, contracttype, symbol_short, Address,
BytesN, Env, Symbol, Vec,
@@ -24,49 +42,79 @@ pub use types::{DataKey, Proposal};
const BADGE_NFT_KEY: Symbol = symbol_short!("badge");
+/// Badge mirror type used for cross-contract vote-weight queries.
+///
+/// Matches the `Badge` struct in the BadgeNFT crate so the generated
+/// `BadgeNFTClient` can deserialize the response without importing
+/// the badge-nft crate directly.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Badge {
+ /// Course ID the badge represents.
pub course_id: u32,
+ /// Ledger timestamp at mint time.
pub minted_at: u64,
}
+/// Cross-contract client interface for vote-weight queries.
+///
+/// Only `get_badges` is needed; the governance contract counts the
+/// length of the returned vector to derive the voter's weight.
#[contractclient(name = "BadgeNFTClient")]
pub trait BadgeNFTInterface {
+ /// Returns all badges currently held by `learner`.
fn get_badges(env: Env, learner: Address) -> Vec;
}
#[contract]
pub struct Governance;
+/// Emitted when a proposal is successfully executed.
#[contractevent]
pub struct ProposalExecuted {
+ /// ID of the executed proposal.
#[topic]
pub proposal_id: u32,
+ /// Proposer address from the proposal record.
pub proposer: Address,
}
+/// Emitted when a proposal is cancelled.
#[contractevent]
pub struct ProposalCancelled {
+ /// ID of the cancelled proposal.
#[topic]
pub proposal_id: u32,
+ /// Address that triggered the cancellation.
pub cancelled_by: Address,
}
+/// Emitted when the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: BytesN<32>,
}
#[contractimpl]
impl Governance {
/// Initializes the governance contract with the admin and BadgeNFT contract address.
- /// Must be called once upon deployment.
- /// Bootstrap with admin and the BadgeNFT contract address used for
- /// vote-weight computation. The `BADGE_NFT_KEY` symbol constant
- /// names the instance slot.
+ ///
+ /// Stores `admin` under `DataKey::Admin` and `badge_contract_address` under the
+ /// `BADGE_NFT_KEY` symbol in instance storage. Must be called exactly once.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Protocol admin address (required auth).
+ /// * `badge_contract_address` — Address of the wired BadgeNFT contract used
+ /// for vote-weight computation.
+ ///
+ /// # Panics
+ ///
+ /// * `"Already initialized"` — if `BADGE_NFT_KEY` already exists in instance storage.
pub fn initialize(env: Env, admin: Address, badge_contract_address: Address) {
if env.storage().instance().has(&BADGE_NFT_KEY) {
panic!("Already initialized");
@@ -78,10 +126,19 @@ impl Governance {
.set(&BADGE_NFT_KEY, &badge_contract_address);
}
- /// Returns the proposal stored for the given proposal ID.
- /// Reads a Proposal struct from persistent storage by ID. The
- /// function panics with `"Proposal not found"` when no
- /// matching `DataKey::Proposal(id)` exists.
+ /// Returns the [`Proposal`] stored for the given ID.
+ ///
+ /// # Arguments
+ ///
+ /// * `proposal_id` — ID of the proposal to fetch.
+ ///
+ /// # Returns
+ ///
+ /// The [`Proposal`] struct stored under `DataKey::Proposal(proposal_id)`.
+ ///
+ /// # Panics
+ ///
+ /// * `"Proposal not found"` — if no matching record exists.
pub fn get_proposal(env: Env, proposal_id: u32) -> Proposal {
env.storage()
.persistent()
@@ -89,11 +146,31 @@ impl Governance {
.expect("Proposal not found")
}
- /// Casts a vote on a proposal, weighted by the number of badges the voter owns.
- /// Records a voter's ballot weighted by the number of badges
- /// the voter owns at the moment of the call. Double-voting is
- /// blocked via the per-(voter, proposal_id) flag in
- /// `DataKey::UserVote`.
+ /// Casts a vote on a proposal, weighted by the voter's current badge count.
+ ///
+ /// Vote weight equals `BadgeNFTClient::get_badges(voter).len()` at the time
+ /// of the call. Double-voting is blocked by a per-`(voter, proposal_id)` flag
+ /// stored in `DataKey::UserVote`.
+ ///
+ /// # Arguments
+ ///
+ /// * `voter` — Address casting the vote (required auth).
+ /// * `proposal_id` — ID of the proposal to vote on.
+ /// * `support` — `true` to vote for, `false` to vote against.
+ ///
+ /// # Panics
+ ///
+ /// * `"Already voted"` — if the voter has already cast a ballot on this proposal.
+ /// * `"Contract not initialized"` — if the BadgeNFT address is absent.
+ /// * `"Vote overflow"` — if the vote-count addition overflows `u32`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.cast_vote(&voter, &proposal_id, &true);
+ /// let proposal = client.get_proposal(&proposal_id);
+ /// assert!(proposal.votes_for > 0);
+ /// ```
pub fn cast_vote(env: Env, voter: Address, proposal_id: u32, support: bool) {
voter.require_auth();
@@ -127,10 +204,20 @@ impl Governance {
env.storage().persistent().set(&vote_key, &true);
}
- /// Upgrades the contract WASM. Only callable by the Protocol Admin.
- /// Replaces the Governance WASM with the supplied hash on the
- /// Soroban host. Admin-only. Emits `ContractUpgraded` on
- /// successful deployment.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the Governance WASM on the Soroban host and emits
+ /// [`ContractUpgraded`] on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
@@ -151,11 +238,24 @@ impl Governance {
.publish(&env);
}
- /// Cancels an active proposal. Only callable by the proposer or the Protocol Admin.
- /// Proposer- or admin-only cancellation of an active proposal.
- /// Sets `proposal.executed = true` (the canonical "locked"
- /// state) and emits `ProposalCancelled`. Rejects cancel
- /// attempts after voting ends or after execution.
+ /// Cancels an active proposal. Callable by the proposer or the protocol admin.
+ ///
+ /// Sets `proposal.executed = true` (the canonical "locked" state) and emits
+ /// [`ProposalCancelled`]. Attempts to cancel after the voting window ends,
+ /// or after execution, both panic.
+ ///
+ /// # Arguments
+ ///
+ /// * `caller` — Proposer or admin address (required auth).
+ /// * `proposal_id` — ID of the proposal to cancel.
+ ///
+ /// # Panics
+ ///
+ /// * `"Proposal not found"` — if no matching record exists.
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `caller` is neither the proposer nor the admin.
+ /// * `"Voting ended"` — if `ledger.timestamp() >= proposal.end_time`.
+ /// * `"Already executed"` — if `proposal.executed` is already `true`.
pub fn cancel_proposal(env: Env, caller: Address, proposal_id: u32) {
caller.require_auth();
@@ -186,12 +286,30 @@ impl Governance {
.publish(&env);
}
- /// Executes a proposal if it has passed and the voting period has ended.
- /// Marks the proposal as executed so the admin knows to action the approved change.
- /// Marks a passed proposal as executed if voting is closed and
- /// strictly more votes were cast in favor than against. Tied votes
- /// panic with `"Proposal rejected"`. Re-execution panics with
- /// `"Already executed"`.
+ /// Executes a passed proposal after its voting period has ended.
+ ///
+ /// Marks the proposal as executed so the admin knows to action the approved
+ /// change off-chain. Strictly more `votes_for` than `votes_against` is
+ /// required; tied or failing votes panic.
+ ///
+ /// # Arguments
+ ///
+ /// * `proposal_id` — ID of the proposal to execute.
+ ///
+ /// # Panics
+ ///
+ /// * `"Proposal not found"` — if no matching record exists.
+ /// * `"Voting still active"` — if `ledger.timestamp() <= proposal.end_time`.
+ /// * `"Proposal rejected"` — if `votes_for <= votes_against`.
+ /// * `"Already executed"` — if `proposal.executed` is already `true`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// // advance ledger past end_time, then:
+ /// client.execute_proposal(&proposal_id);
+ /// assert!(client.get_proposal(&proposal_id).executed);
+ /// ```
pub fn execute_proposal(env: Env, proposal_id: u32) {
let mut proposal = Self::get_proposal(env.clone(), proposal_id);
diff --git a/contracts/governance/src/types.rs b/contracts/governance/src/types.rs
index b2536e7..5c689f8 100644
--- a/contracts/governance/src/types.rs
+++ b/contracts/governance/src/types.rs
@@ -1,21 +1,60 @@
+//! Shared types for the Governance contract.
+
use soroban_sdk::{contracttype, Address, BytesN};
+/// An on-chain governance proposal.
+///
+/// Stored in persistent storage under `DataKey::Proposal(id)`.
+///
+/// ## Lifecycle
+///
+/// ```text
+/// created → voting open → (executed | cancelled)
+/// ```
+///
+/// Cancellation reuses `executed = true` as the "locked" sentinel so that
+/// both paths share the same re-entry guard in `execute_proposal` and
+/// `cancel_proposal`.
+///
+/// ## Vote weight
+///
+/// Weight is fetched from the BadgeNFT contract at cast time, not at
+/// proposal creation time. Badge holdings at the moment of the vote
+/// determine the voter's influence.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Proposal {
+ /// Auto-assigned proposal ID.
pub id: u32,
+ /// Address that created the proposal.
pub proposer: Address,
+ /// 32-byte IPFS hash of the proposal description and rationale.
pub metadata_hash: BytesN<32>,
+ /// Cumulative badge-weighted votes in favour.
pub votes_for: u32,
+ /// Cumulative badge-weighted votes against.
pub votes_against: u32,
+ /// Unix timestamp (seconds) at which the voting window closes.
pub end_time: u64,
+ /// `true` once the proposal has been executed **or** cancelled.
pub executed: bool,
}
+/// Storage keys used by the Governance contract.
+///
+/// | Variant | Storage tier | Type | Description |
+/// |---------|-------------|------|-------------|
+/// | `Proposal(u32)` | Persistent | [`Proposal`] | Proposal record by ID |
+/// | `UserVote(Address, u32)` | Persistent | `bool` | Double-vote guard |
+/// | `Admin` | Instance | `Address` | Protocol admin address |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Persistent storage key for a [`Proposal`] identified by `u32` ID.
Proposal(u32),
+ /// Persistent storage key preventing double-voting.
+ /// Tuple: `(voter_address, proposal_id)`.
UserVote(Address, u32),
+ /// Instance storage key for the protocol admin [`Address`].
Admin,
}
diff --git a/contracts/quest-engine/src/lib.rs b/contracts/quest-engine/src/lib.rs
index 50c281a..d3b1041 100644
--- a/contracts/quest-engine/src/lib.rs
+++ b/contracts/quest-engine/src/lib.rs
@@ -1,20 +1,38 @@
+//! # Quest Engine Contract
+//!
+//! Build and Explore quest lifecycle for the Orivex protocol.
+//!
+//! * **Build quests** — employer-funded bounties. Reward tokens are locked
+//! in the contract at creation; the employer reviews submissions and splits
+//! the locked amount 85 % (learner) / 15 % (platform fee → RewardPool).
+//! The learner's share is further scaled by their [`StakeVault`] multiplier.
+//!
+//! * **Explore quests** — admin-verified off-chain actions. No tokens are
+//! locked up front; on verification the contract triggers
+//! `RewardPool::distribute_reward` directly.
+//!
+//! ## Operational notes
+//!
+//! * Review paths call `StakeVault::get_multiplier` for payout scaling.
+//! * Explore-quest payouts route via `RewardPool::distribute_reward`, which
+//! requires the QuestEngine to be whitelisted as an approved spender.
+//! * The global [`IsPaused`](types::DataKey::IsPaused) flag blocks
+//! `review_submission` and `batch_review_submissions` when set.
+
#![no_std]
+/// Storage-key prefix constant for Build quest identifiers (informational).
pub const BUILD_QUEST_PREFIX: &str = "build";
+/// Storage-key prefix constant for Explore quest identifiers (informational).
pub const EXPLORE_QUEST_PREFIX: &str = "explore";
-// Operational notes — review paths cross-call
-// `StakeVault.get_multiplier` for payout scaling. Explore-quest
-// payouts route via `RewardPool.distribute_reward` (which
-// requires the QuestEngine to be whitelisted on the
-// RewardPool).
+/// Hard cap on the reward amount for a single quest (in token base units).
pub const MAX_QUEST_REWARD: i128 = 1_000_000_000_000_000;
+/// Platform fee expressed in basis points (1500 bp = 15 %).
+/// Deducted from Build-quest `reward_amount` before the learner payout.
pub const PLATFORM_FEE_BASIS_POINTS: u32 = 1500;
-// Crate overview — Build and Explore quests. Build quests are
-// employer-funded and reviewed per submission. Explore quests are
-// admin-verified and rewarded out of the RewardPool.
pub mod types;
use types::{DataKey, Quest, QuestType, Submission, SubmissionStatus};
@@ -23,78 +41,116 @@ use soroban_sdk::{
contract, contractclient, contractevent, contractimpl, token, Address, BytesN, Env, Vec,
};
+/// Cross-contract client interface for the StakeVault contract.
+///
+/// Used by `review_submission` to fetch the learner's payout multiplier
+/// without importing the StakeVault crate directly.
#[contractclient(name = "StakeVaultClient")]
pub trait StakeVaultInterface {
+ /// Returns the basis-points multiplier for `learner`'s current stake.
fn get_multiplier(env: Env, learner: Address) -> u32;
}
+/// Cross-contract client interface for the RewardPool contract.
+///
+/// Used by `verify_explore_quest` to trigger USDC payouts from the pool.
#[contractclient(name = "RewardPoolClient")]
pub trait RewardPoolInterface {
+ /// Distributes `amount` tokens to `learner` from the pool.
fn distribute_reward(env: Env, caller: Address, learner: Address, amount: i128);
}
+/// Emitted when a new quest is created (Build or Explore).
#[contractevent]
pub struct QuestCreated {
+ /// Address of the employer (Build) or admin (Explore) who created the quest.
#[topic]
pub employer: Address,
+ /// Auto-assigned quest ID.
#[topic]
pub quest_id: u32,
+ /// Tokens locked (Build) or earmarked from RewardPool (Explore).
pub reward_amount: i128,
}
+/// Emitted when a learner submits proof for a Build quest.
#[contractevent]
pub struct ProofSubmitted {
+ /// Learner who submitted the proof.
#[topic]
pub learner: Address,
+ /// Quest the proof targets.
#[topic]
pub quest_id: u32,
+ /// IPFS or on-chain hash of the proof artifact.
pub proof_hash: BytesN<32>,
}
+/// Emitted after an employer approves or rejects a single submission.
#[contractevent]
pub struct SubmissionReviewed {
+ /// Employer who performed the review.
#[topic]
pub employer: Address,
+ /// Learner whose submission was reviewed.
#[topic]
pub learner: Address,
+ /// Quest the submission belongs to.
#[topic]
pub quest_id: u32,
+ /// `true` if approved, `false` if rejected.
pub approved: bool,
}
+/// Emitted when an employer cancels a Build quest and reclaims the locked funds.
#[contractevent]
pub struct QuestRefunded {
+ /// Employer who initiated the refund.
#[topic]
pub employer: Address,
+ /// Quest that was cancelled.
#[topic]
pub quest_id: u32,
+ /// Amount returned to the employer.
pub amount: i128,
}
+/// Emitted at the end of a successful `batch_review_submissions` call.
#[contractevent]
pub struct BatchReviewed {
+ /// Employer who performed the batch review.
#[topic]
pub employer: Address,
+ /// Quest all submissions belonged to.
#[topic]
pub quest_id: u32,
+ /// Number of submissions approved in this batch.
pub approved_count: u32,
}
+/// Emitted when the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: BytesN<32>,
}
+/// Emitted when the admin verifies an Explore quest completion and triggers a payout.
#[contractevent]
pub struct ExploreQuestVerified {
+ /// Admin who verified the completion.
#[topic]
pub admin: Address,
+ /// Learner who completed the off-chain action.
#[topic]
pub learner: Address,
+ /// Explore quest that was verified.
#[topic]
pub quest_id: u32,
+ /// Reward amount distributed from the RewardPool.
pub amount: i128,
}
@@ -103,7 +159,21 @@ pub struct QuestEngineContract;
#[contractimpl]
impl QuestEngineContract {
- /// Initializes the QuestEngine contract with the token address and admin.
+ /// Initializes the QuestEngine with its admin, token, RewardPool, and StakeVault addresses.
+ ///
+ /// Stores all four addresses in instance storage and sets the quest counter to zero.
+ /// Must be called exactly once after deployment.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Protocol admin (required auth).
+ /// * `token` — USDC token address used for Build-quest payouts.
+ /// * `reward_pool` — Address of the wired RewardPool contract.
+ /// * `stake_vault` — Address of the wired StakeVault contract.
+ ///
+ /// # Panics
+ ///
+ /// * `"Already initialized"` — if `DataKey::Token` is already set.
pub fn initialize(
env: Env,
admin: Address,
@@ -126,56 +196,70 @@ impl QuestEngineContract {
env.storage().instance().set(&DataKey::QuestCounter, &0u32);
}
- /// Toggles the pause state of the contract (emergency circuit breaker).
+ /// Toggles the global pause state of the contract.
+ ///
+ /// When paused, `review_submission` and `batch_review_submissions` panic
+ /// with `"Contract is paused"`. Admin-only circuit-breaker for incidents.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `status` - The pause status (true = paused, false = unpaused)
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `status` — `true` to pause, `false` to unpause.
///
/// # Panics
- /// * If contract is not initialized
- /// * If admin does not match stored admin
- /// * If admin authentication fails
- /// Sets the `IsPaused` flag in instance storage as a circuit
- /// breaker. Admin-only. When true, `review_submission` and
- /// `batch_review_submissions` panic early with
- /// `"Contract is paused"`.
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn set_pause(env: Env, admin: Address, status: bool) {
- // 1. Fetch 'Admin' address from Instance storage
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
- // 2. Assert admin == stored_admin
if admin != stored_admin {
panic!("Unauthorized");
}
- // 3. admin.require_auth()
admin.require_auth();
- // 4. Store pause status in Instance storage
env.storage().instance().set(&DataKey::IsPaused, &status);
}
- /// Allows an employer to lock USDC directly in the QuestEngine contract.
- /// This acts as an isolated vault specifically for B2B bounties.
- /// Employer-funded quest that is funded out of the employer's
- /// balance at create time. The full `reward_amount` is locked in
- /// the QuestEngine contract; review actions later split it 85 / 15
- /// between learner and reward-pool.
+ /// Creates a Build quest, locking `reward_amount` tokens from the employer.
+ ///
+ /// The full reward is transferred from the employer's wallet into the
+ /// QuestEngine contract at creation time. On approval the amount is split
+ /// 85 % (learner, adjusted by staking multiplier) / 15 % (platform fee).
+ ///
+ /// # Arguments
+ ///
+ /// * `employer` — Address funding the quest (required auth).
+ /// * `reward_amount` — Tokens to lock; transferred immediately.
+ /// * `metadata_hash` — 32-byte IPFS hash of the quest description.
+ ///
+ /// # Returns
+ ///
+ /// The auto-assigned `u32` quest ID.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// let id = client.create_build_quest(&employer, &1000_0000000i128, &hash);
+ /// assert!(client.get_quest(&id).is_some());
+ /// ```
pub fn create_build_quest(
env: Env,
employer: Address,
reward_amount: i128,
metadata_hash: BytesN<32>,
) -> u32 {
- // 1. employer.require_auth()
employer.require_auth();
- // 2. Fetch token_client for the USDC asset.
let token_address: Address = env
.storage()
.instance()
@@ -183,10 +267,8 @@ impl QuestEngineContract {
.expect("Not initialized");
let token_client = token::Client::new(&env, &token_address);
- // 3. call token_client.transfer(employer, env.current_contract_address(), reward_amount).
token_client.transfer(&employer, env.current_contract_address(), &reward_amount);
- // 4. Increment Quest ID counter.
let mut quest_id: u32 = env
.storage()
.instance()
@@ -197,7 +279,6 @@ impl QuestEngineContract {
.instance()
.set(&DataKey::QuestCounter, &quest_id);
- // 5. Create Quest struct with QuestType::Build.
let quest = Quest {
employer: employer.clone(),
reward_amount,
@@ -206,12 +287,10 @@ impl QuestEngineContract {
active: true,
};
- // 6. Save to Persistent storage.
env.storage()
.persistent()
.set(&DataKey::Quest(quest_id), &quest);
- // 7. Emit QuestCreated event.
QuestCreated {
employer,
quest_id,
@@ -222,35 +301,34 @@ impl QuestEngineContract {
quest_id
}
- /// Creates an Explore Quest that will be funded by the RewardPool.
- /// Explore Quests are for off-chain actions verified by the admin.
+ /// Creates an Explore quest funded by the RewardPool on admin verification.
+ ///
+ /// No tokens are locked in the QuestEngine. When the admin later calls
+ /// `verify_explore_quest`, the reward is pulled from the configured
+ /// RewardPool via `distribute_reward`.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `reward_amount` - The amount to be paid from RewardPool upon verification
- /// * `metadata_hash` - Hash of the quest metadata (description, requirements, etc.)
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `reward_amount` — Amount the RewardPool will pay on verification.
+ /// * `metadata_hash` — 32-byte IPFS hash of the quest description.
///
/// # Returns
- /// The ID of the newly created quest
+ ///
+ /// The auto-assigned `u32` quest ID.
///
/// # Panics
- /// * If admin authentication fails
- /// * If admin does not match stored admin
- /// * If contract is not initialized
- /// Admin-only creation of an Explore Quest that the RewardPool
- /// will fund on verification. The employer field is set to the
- /// admin so that downstream payout flows can route via the
- /// RewardPool's `distribute_reward` call.
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn create_explore_quest(
env: Env,
admin: Address,
reward_amount: i128,
metadata_hash: BytesN<32>,
) -> u32 {
- // 1. admin.require_auth()
admin.require_auth();
- // 2. Verify admin
let stored_admin: Address = env
.storage()
.instance()
@@ -258,7 +336,6 @@ impl QuestEngineContract {
.expect("Not initialized");
assert!(admin == stored_admin, "Unauthorized");
- // 3. Increment Quest ID counter
let mut quest_id: u32 = env
.storage()
.instance()
@@ -269,7 +346,6 @@ impl QuestEngineContract {
.instance()
.set(&DataKey::QuestCounter, &quest_id);
- // 4. Create Quest struct with QuestType::Explore
let quest = Quest {
employer: admin.clone(),
reward_amount,
@@ -278,12 +354,10 @@ impl QuestEngineContract {
active: true,
};
- // 5. Save to Persistent storage
env.storage()
.persistent()
.set(&DataKey::Quest(quest_id), &quest);
- // 6. Emit QuestCreated event
QuestCreated {
employer: admin,
quest_id,
@@ -294,24 +368,40 @@ impl QuestEngineContract {
quest_id
}
- /// Returns a quest by its ID.
- /// Reads a Quest struct from persistent storage by ID. Returns
- /// `None` when the ID has no record so callers can branch on
- /// presence rather than panic.
+ /// Returns the [`Quest`] for the given ID, or `None` if not found.
+ ///
+ /// # Arguments
+ ///
+ /// * `quest_id` — The quest ID to look up.
+ ///
+ /// # Returns
+ ///
+ /// `Some(Quest)` if found, `None` otherwise — callers can branch on
+ /// presence without panicking.
pub fn get_quest(env: Env, quest_id: u32) -> Option {
env.storage().persistent().get(&DataKey::Quest(quest_id))
}
- /// Allows a learner to submit proof for a build quest.
- /// Stores a learner's proof hash for the given build quest in
- /// `DataKey::Submission`. The associated quest must be active and
- /// of `QuestType::Build`. Re-submission for the same pair panics
- /// with `"Submission already exists"`.
+ /// Stores a learner's proof for a Build quest.
+ ///
+ /// The quest must be active and of [`QuestType::Build`]. Each
+ /// `(learner, quest_id)` pair may only have one submission; re-submission panics.
+ ///
+ /// # Arguments
+ ///
+ /// * `learner` — Address submitting the proof (required auth).
+ /// * `quest_id` — ID of the Build quest.
+ /// * `proof_hash` — 32-byte IPFS or on-chain hash of the proof artifact.
+ ///
+ /// # Panics
+ ///
+ /// * `"Quest not found"` — if the quest ID does not exist.
+ /// * `"Quest is not active"` — if the quest has been deactivated.
+ /// * `"Only Build quests accept submissions"` — if quest type is `Explore`.
+ /// * `"Submission already exists"` — if a submission already exists for this pair.
pub fn submit_proof(env: Env, learner: Address, quest_id: u32, proof_hash: BytesN<32>) {
- // 1. learner.require_auth()
learner.require_auth();
- // 2. Retrieve Quest. Assert it is active and QuestType == Build.
let quest: Quest = env
.storage()
.persistent()
@@ -324,22 +414,18 @@ impl QuestEngineContract {
panic!("Only Build quests accept submissions");
}
- // 3. Construct DataKey::Submission(learner, quest_id).
let submission_key = DataKey::Submission(learner.clone(), quest_id);
- // 4. Assert a submission doesn't already exist.
if env.storage().persistent().has(&submission_key) {
panic!("Submission already exists");
}
- // 5. Save struct { proof_hash, status: SubmissionStatus::Pending } to storage.
let submission = Submission {
proof_hash: proof_hash.clone(),
status: SubmissionStatus::Pending,
};
env.storage().persistent().set(&submission_key, &submission);
- // 6. Emit ProofSubmitted event.
ProofSubmitted {
learner,
quest_id,
@@ -348,21 +434,35 @@ impl QuestEngineContract {
.publish(&env);
}
- /// Returns a submission by learner and quest ID.
- /// Reads a learner's Submission struct for a given quest.
- /// `None` indicates no submission has been recorded yet for the
- /// (learner, quest_id) pair.
+ /// Returns the [`Submission`] for a given learner and quest, or `None`.
+ ///
+ /// `None` means the learner has not yet submitted proof for `quest_id`.
pub fn get_submission(env: Env, learner: Address, quest_id: u32) -> Option {
env.storage()
.persistent()
.get(&DataKey::Submission(learner, quest_id))
}
- /// Allows an employer to review and approve/reject a learner's submission.
- /// Approves or rejects a single submission, applying the staking
- /// multiplier from the configured StakeVault. The boosted learner
- /// payout is capped at the available post-fee balance so that
- /// employer-funded quests can never go negative.
+ /// Approves or rejects a single learner submission for a Build quest.
+ ///
+ /// On approval the reward is split: 15 % platform fee goes to the RewardPool
+ /// address and the remainder to the learner, scaled by the learner's
+ /// [`StakeVault`] multiplier (capped at the available post-fee balance).
+ ///
+ /// # Arguments
+ ///
+ /// * `employer` — Must equal `quest.employer` (required auth).
+ /// * `learner` — Address whose submission is being reviewed.
+ /// * `quest_id` — ID of the quest.
+ /// * `approve` — `true` to approve and pay; `false` to reject.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract is paused"` — if the pause flag is set.
+ /// * `"Quest not found"` — if the quest ID does not exist.
+ /// * `"Only the quest employer can review submissions"` — wrong caller.
+ /// * `"Submission not found"` — if no submission exists.
+ /// * `"Submission is not pending review"` — if already decided.
pub fn review_submission(
env: Env,
employer: Address,
@@ -370,7 +470,6 @@ impl QuestEngineContract {
quest_id: u32,
approve: bool,
) {
- // 0. Check if contract is paused
let is_paused: bool = env
.storage()
.instance()
@@ -378,10 +477,8 @@ impl QuestEngineContract {
.unwrap_or(false);
assert!(!is_paused, "Contract is paused");
- // 1. employer.require_auth()
employer.require_auth();
- // 2. Retrieve Quest. Assert quest.employer == employer.
let quest: Quest = env
.storage()
.persistent()
@@ -391,7 +488,6 @@ impl QuestEngineContract {
panic!("Only the quest employer can review submissions");
}
- // 3. Retrieve Submission. Assert status == Pending.
let submission_key = DataKey::Submission(learner.clone(), quest_id);
let mut submission: Submission = env
.storage()
@@ -402,9 +498,7 @@ impl QuestEngineContract {
panic!("Submission is not pending review");
}
- // 4. If approve == true:
if approve {
- // a. Fetch token_client.transfer(env.current_contract_address(), learner, quest.reward_amount).
let token_address: Address = env
.storage()
.instance()
@@ -415,7 +509,8 @@ impl QuestEngineContract {
let fee = (quest.reward_amount * 15) / 100;
let base_learner_amount = quest.reward_amount - fee;
- // Fetch stake vault and get multiplier
+ // Multiplier from StakeVault (BPS): 100 = 1.0×, 120 = 1.2×, 200 = 2.0×.
+ // Boost capped to base_learner_amount since employers fund at face value.
let stake_vault_address: Address = env
.storage()
.instance()
@@ -424,14 +519,9 @@ impl QuestEngineContract {
let stake_vault_client = StakeVaultClient::new(&env, &stake_vault_address);
let multiplier = stake_vault_client.get_multiplier(&learner);
- // Apply multiplier (basis points: 100 = 1.0x, 120 = 1.2x, etc.)
- // Note: The boosted amount is calculated but capped to base_learner_amount
- // since the quest only has base_learner_amount available after fees.
- // In production, employers should fund quests accounting for potential multipliers,
- // or the boost should come from a separate reward pool contract with proper authorization.
let calculated_boost = (base_learner_amount * multiplier as i128) / 100;
let learner_amount = if calculated_boost > base_learner_amount {
- base_learner_amount // Cap to available funds
+ base_learner_amount
} else {
calculated_boost
};
@@ -447,15 +537,11 @@ impl QuestEngineContract {
submission.status = SubmissionStatus::Approved;
} else {
- // 5. If approve == false:
- // a. Update submission status to Rejected.
submission.status = SubmissionStatus::Rejected;
}
- // 6. Save updated submission to Persistent storage.
env.storage().persistent().set(&submission_key, &submission);
- // 7. Emit SubmissionReviewed event.
SubmissionReviewed {
employer,
learner,
@@ -465,11 +551,20 @@ impl QuestEngineContract {
.publish(&env);
}
- /// Employer-only cancellation of an in-flight Build quest.
- /// Returns the locked `reward_amount` to the employer's wallet
- /// via the QuestEngine's token client and marks the quest
- /// inactive. Panics with `"Quest already inactive"` if the
- /// quest is already inactive.
+ /// Cancels an active Build quest and returns the locked tokens to the employer.
+ ///
+ /// Marks the quest inactive and transfers `reward_amount` back to the employer.
+ ///
+ /// # Arguments
+ ///
+ /// * `employer` — Must equal `quest.employer` (required auth).
+ /// * `quest_id` — ID of the quest to cancel.
+ ///
+ /// # Panics
+ ///
+ /// * `"Quest not found"` — if the quest ID does not exist.
+ /// * `"Unauthorized"` — if `employer ≠ quest.employer`.
+ /// * `"Quest already inactive"` — if the quest is already cancelled.
pub fn refund_quest(env: Env, employer: Address, quest_id: u32) {
employer.require_auth();
@@ -511,20 +606,31 @@ impl QuestEngineContract {
.publish(&env);
}
- /// Approves multiple learner submissions in a single transaction.
- /// Executes the full fee-adjusted payout for each learner.
- /// Approves a vector of learner submissions against a single
- /// quest. Each submission must be `Pending`; the function
- /// panics on the first non-pending submission. Emits both
- /// individual `SubmissionReviewed` events and a single
- /// `BatchReviewed` summary event with the approved count.
+ /// Approves all submissions in `learners` for a single quest in one transaction.
+ ///
+ /// Each submission must be `Pending`; the function panics on the first
+ /// non-pending entry. Emits one [`SubmissionReviewed`] per learner and a
+ /// single [`BatchReviewed`] summary at the end.
+ ///
+ /// # Arguments
+ ///
+ /// * `employer` — Must equal `quest.employer` (required auth).
+ /// * `quest_id` — ID of the quest.
+ /// * `learners` — Ordered list of learner addresses to approve.
+ ///
+ /// # Panics
+ ///
+ /// * `"Contract is paused"` — if the pause flag is set.
+ /// * `"Quest not found"` — if the quest ID does not exist.
+ /// * `"Only the quest employer can review submissions"` — wrong caller.
+ /// * `"Submission not found"` — if a learner has no submission.
+ /// * `"Submission is not pending review"` — if already decided.
pub fn batch_review_submissions(
env: Env,
employer: Address,
quest_id: u32,
learners: Vec,
) {
- // 0. Check if contract is paused
let is_paused: bool = env
.storage()
.instance()
@@ -597,7 +703,20 @@ impl QuestEngineContract {
.publish(&env);
}
- /// Upgrades the contract WASM. Only callable by the Protocol Admin.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the QuestEngine WASM on the Soroban host and emits
+ /// [`ContractUpgraded`] on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
@@ -618,29 +737,27 @@ impl QuestEngineContract {
.publish(&env);
}
- /// Verifies an Explore Quest completion and triggers payout from RewardPool.
- /// Only the admin can call this function to reward off-chain actions.
+ /// Verifies an Explore quest completion and triggers a RewardPool payout.
+ ///
+ /// Admin-only confirmation that a learner completed an off-chain action.
+ /// Triggers a cross-contract `distribute_reward` call; the QuestEngine
+ /// must be whitelisted as an approved spender on the RewardPool.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `learner` - The learner address to receive the reward
- /// * `quest_id` - The ID of the Explore Quest to verify
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `learner` — Address to receive the reward.
+ /// * `quest_id` — ID of the Explore quest being verified.
///
/// # Panics
- /// * If admin authentication fails
- /// * If admin does not match stored admin
- /// * If quest is not found
- /// * If quest type is not Explore
- /// * If contract is not initialized
- /// Admin-only confirmation that a learner completed an off-chain
- /// action. Triggers a cross-contract `distribute_reward` call into
- /// the configured RewardPool. The QuestEngine must be whitelisted
- /// as an approved spender on RewardPool.
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
+ /// * `"Quest not found"` — if the quest ID does not exist.
+ /// * `"Not an Explore quest"` — if the quest type is `Build`.
pub fn verify_explore_quest(env: Env, admin: Address, learner: Address, quest_id: u32) {
- // 1. admin.require_auth()
admin.require_auth();
- // 2. Verify admin
let stored_admin: Address = env
.storage()
.instance()
@@ -648,20 +765,17 @@ impl QuestEngineContract {
.expect("Not initialized");
assert!(admin == stored_admin, "Unauthorized");
- // 3. Get quest
let quest: Quest = env
.storage()
.persistent()
.get(&DataKey::Quest(quest_id))
.expect("Quest not found");
- // 4. Assert quest type is Explore
assert!(
quest.quest_type == QuestType::Explore,
"Not an Explore quest"
);
- // 5. Get reward pool address and create client
let reward_pool_address: Address = env
.storage()
.instance()
@@ -669,14 +783,12 @@ impl QuestEngineContract {
.expect("Not initialized");
let reward_pool_client = RewardPoolClient::new(&env, &reward_pool_address);
- // 6. Distribute reward from RewardPool
reward_pool_client.distribute_reward(
&env.current_contract_address(),
&learner,
&quest.reward_amount,
);
- // 7. Emit ExploreQuestVerified event
ExploreQuestVerified {
admin,
learner,
diff --git a/contracts/quest-engine/src/types.rs b/contracts/quest-engine/src/types.rs
index 531a86f..8b18175 100644
--- a/contracts/quest-engine/src/types.rs
+++ b/contracts/quest-engine/src/types.rs
@@ -1,46 +1,99 @@
+//! Shared types for the QuestEngine contract.
+
use soroban_sdk::{contracttype, Address, BytesN};
+/// Discriminates between the two quest funding models.
+///
+/// | Variant | Funded by | Reviewed by | Payout source |
+/// |---------|-----------|-------------|---------------|
+/// | `Build` | Employer (locked on creation) | Employer | QuestEngine vault |
+/// | `Explore` | RewardPool | Protocol admin | RewardPool via `distribute_reward` |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum QuestType {
+ /// Employer-funded bounty. Reward tokens are locked in the QuestEngine
+ /// contract at creation time and released on submission approval.
Build,
+ /// Admin-verified off-chain action. Reward comes from the RewardPool;
+ /// no tokens are locked in the QuestEngine itself.
Explore,
}
+/// An on-chain quest record, keyed by auto-incremented ID.
+///
+/// Stored in persistent storage under `DataKey::Quest(id)`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Quest {
+ /// Address of the employer (Build) or admin (Explore) who created the quest.
pub employer: Address,
+ /// Reward tokens allocated for this quest.
+ /// * Build: locked in the contract at creation.
+ /// * Explore: pulled from the RewardPool on verification.
pub reward_amount: i128,
+ /// Funding and review model for this quest.
pub quest_type: QuestType,
+ /// IPFS hash of the quest description, requirements, and evaluation rubric.
pub metadata_hash: BytesN<32>,
+ /// Whether the quest is still accepting submissions or verification.
+ /// Set to `false` after `refund_quest` is called.
pub active: bool,
}
+/// Lifecycle states for a learner's proof submission.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubmissionStatus {
+ /// Submitted and awaiting employer review.
Pending,
+ /// Approved by the employer; reward has been transferred.
Approved,
+ /// Rejected by the employer; no reward issued.
Rejected,
}
+/// A proof submission from a learner for a Build quest.
+///
+/// Stored in persistent storage under `DataKey::Submission(learner, quest_id)`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Submission {
+ /// IPFS or on-chain hash of the learner's proof artifact.
pub proof_hash: BytesN<32>,
+ /// Current lifecycle state of this submission.
pub status: SubmissionStatus,
}
+/// Storage keys used by the QuestEngine contract.
+///
+/// | Variant | Storage tier | Type | Description |
+/// |---------|-------------|------|-------------|
+/// | `Admin` | Instance | `Address` | Protocol admin address |
+/// | `Quest(u32)` | Persistent | [`Quest`] | Quest record by ID |
+/// | `Submission(Address, u32)` | Persistent | [`Submission`] | Per-learner submission |
+/// | `Token` | Instance | `Address` | USDC token address |
+/// | `QuestCounter` | Instance | `u32` | Auto-increment quest ID |
+/// | `RewardPool` | Instance | `Address` | Wired RewardPool contract |
+/// | `IsPaused` | Instance | `bool` | Global pause circuit-breaker |
+/// | `StakeVault` | Instance | `Address` | Wired StakeVault contract |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Instance storage key for the protocol admin [`Address`].
Admin,
+ /// Persistent storage key for a [`Quest`] identified by `u32` ID.
Quest(u32),
- Submission(Address, u32), // (Submitter Address, Quest ID)
+ /// Persistent storage key for a learner's [`Submission`] on a given quest.
+ /// Tuple: `(submitter_address, quest_id)`.
+ Submission(Address, u32),
+ /// Instance storage key for the USDC token [`Address`].
Token,
+ /// Instance storage key for the current quest ID counter.
QuestCounter,
+ /// Instance storage key for the RewardPool contract [`Address`].
RewardPool,
+ /// Instance storage key for the global pause flag.
IsPaused,
+ /// Instance storage key for the StakeVault contract [`Address`].
StakeVault,
}
diff --git a/contracts/reward-pool/src/lib.rs b/contracts/reward-pool/src/lib.rs
index be5cb21..a6234e1 100644
--- a/contracts/reward-pool/src/lib.rs
+++ b/contracts/reward-pool/src/lib.rs
@@ -1,78 +1,132 @@
+//! # Reward Pool Contract
+//!
+//! Central USDC reward distribution hub for the Orivex protocol.
+//! Holds the reward-token balance and gates all payouts behind an
+//! approved-spender allowlist, so only whitelisted contracts
+//! (e.g. `CourseRegistry`, `QuestEngine`) can call
+//! `distribute_reward`.
+//!
+//! ## Operational notes
+//!
+//! * The [`IsPaused`](types::DataKey::IsPaused) flag is a global
+//! circuit-breaker for `distribute_reward`.
+//! * Fund recovery always routes through `emergency_sweep`; never
+//! via direct token transfer.
+//! * Spender-list entries live in **persistent** storage and survive
+//! contract upgrades.
+//!
+//! ## Storage layout
+//!
+//! | Key | Tier | Type | Description |
+//! |-----|------|------|-------------|
+//! | `DataKey::Admin` | Instance | `Address` | Protocol admin |
+//! | `DataKey::Token` | Instance | `Address` | SAC reward token |
+//! | `DataKey::Spender(addr)` | Persistent | `bool` | Whitelist flag |
+//! | `DataKey::IsPaused` | Instance | `bool` | Pause switch |
+
#![no_std]
+/// Number of decimal places used by the reward token (Stellar standard).
pub const REWARD_TOKEN_DECIMALS: u32 = 7;
+/// Maximum number of addresses that can be whitelisted as approved spenders.
pub const MAX_SPENDERS: u32 = 256;
-// Operational notes — the `IsPaused` flag is a global switch
-// for `distribute_reward`. Fund recovery always routes via
-// `emergency_sweep`, never via direct token transfer. Spender
-// list entries are stored in persistent storage and persist
-// across upgrades.
+/// Minimum valid payout amount (inclusive). Calls with `amount <= 0` panic.
pub const MIN_PAYOUT_AMOUNT: i128 = 1;
+/// Platform fee expressed in basis points (1500 bp = 15 %).
+/// Applied by callers (e.g. `QuestEngine`) before invoking `distribute_reward`.
pub const PLATFORM_FEE_BASIS_POINTS: u32 = 1500;
-// Crate overview — central USDC reward distribution. Holds the
-// reward-token balance and gates payouts behind an approved-
-// spender allowlist.
use soroban_sdk::{contractclient, contractevent, Address, BytesN, Env};
pub mod types;
+/// Public interface for the RewardPool contract.
+///
+/// `#[contractclient]` generates `RewardPoolClient` from this trait so that
+/// other contracts (e.g. `CourseRegistry`, `QuestEngine`) can trigger payouts
+/// via cross-contract calls without importing the concrete implementation.
#[contractclient(name = "RewardPoolClient")]
pub trait RewardPoolInterface {
+ /// See [`contract_impl::RewardPool::initialize`].
fn initialize(env: Env, admin: Address, token: Address);
+ /// See [`contract_impl::RewardPool::add_approved_spender`].
fn add_approved_spender(env: Env, admin: Address, spender: Address);
+ /// See [`contract_impl::RewardPool::set_pause`].
fn set_pause(env: Env, admin: Address, status: bool);
+ /// See [`contract_impl::RewardPool::distribute_reward`].
fn distribute_reward(env: Env, caller: Address, learner: Address, amount: i128);
+ /// See [`contract_impl::RewardPool::fund_pool`].
fn fund_pool(env: Env, donor: Address, amount: i128);
+ /// See [`contract_impl::RewardPool::emergency_sweep`].
fn emergency_sweep(env: Env, admin: Address, recovery_wallet: Address);
+ /// See [`contract_impl::RewardPool::upgrade_contract`].
fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>);
}
+/// Emitted once when the contract is successfully initialized.
#[contractevent]
pub struct PoolInitialized {
+ /// Protocol admin address recorded at initialization.
#[topic]
pub admin: Address,
+ /// SAC reward-token address recorded at initialization.
#[topic]
pub token: Address,
}
+/// Emitted when a new address is added to the approved-spender whitelist.
#[contractevent]
pub struct SpenderAdded {
+ /// The newly whitelisted spender address.
#[topic]
pub spender: Address,
}
+/// Emitted on every successful `distribute_reward` call.
#[contractevent]
pub struct RewardDistributed {
+ /// Whitelisted spender contract that triggered the payout.
#[topic]
pub caller: Address,
+ /// Learner address that received the tokens.
#[topic]
pub learner: Address,
+ /// Token amount transferred (in reward-token decimals).
pub amount: i128,
}
+/// Emitted when a donor tops up the pool's token balance.
#[contractevent]
pub struct PoolFunded {
+ /// Donor address that sent the tokens.
#[topic]
pub donor: Address,
+ /// Amount donated (in reward-token decimals).
pub amount: i128,
}
+/// Emitted when the admin drains the entire pool balance to a recovery wallet.
#[contractevent]
pub struct EmergencySweep {
+ /// Admin who authorized the sweep.
#[topic]
pub admin: Address,
+ /// Wallet that received the swept tokens.
#[topic]
pub recovery_wallet: Address,
+ /// Full token balance that was swept.
pub amount: i128,
}
+/// Emitted when the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: BytesN<32>,
}
@@ -91,128 +145,135 @@ mod contract_impl {
#[contractimpl]
impl RewardPool {
- /// Initializes the RewardPool contract with admin and token addresses.
+ /// Initializes the RewardPool contract with the admin and reward-token addresses.
+ ///
+ /// Stores both addresses in instance storage and emits [`PoolInitialized`].
+ /// Must be called exactly once after deployment; subsequent calls panic.
///
/// # Arguments
- /// * `admin` - The admin address that will have administrative control
- /// * `token` - The SAC token address to be used as reward token
+ ///
+ /// * `admin` — The protocol admin address (required auth).
+ /// * `token` — The SAC reward-token address.
///
/// # Panics
- /// * If contract is already initialized
- /// * If admin authentication fails
- /// Stores admin and reward-token addresses in instance storage and
- /// emits the `PoolInitialized` event. Both addresses are recorded
- /// on the first call; subsequent calls panic with
- /// `"Already initialized"`.
+ ///
+ /// * `"Already initialized"` — if `DataKey::Admin` already exists.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.initialize(&admin, &token);
+ /// // second call panics with "Already initialized"
+ /// ```
pub fn initialize(env: Env, admin: Address, token: Address) {
- // 1. Check if already initialized
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
}
-
- // 2. Require admin authentication
admin.require_auth();
-
- // 3. Store admin in Instance storage
env.storage().instance().set(&DataKey::Admin, &admin);
-
- // 4. Store token in Instance storage
env.storage().instance().set(&DataKey::Token, &token);
-
- // 5. Emit PoolInitialized event
PoolInitialized { admin, token }.publish(&env);
}
- /// Adds a contract address to the approved spender whitelist.
+ /// Adds a contract address to the approved-spender whitelist.
+ ///
+ /// Whitelisted addresses are permitted to call `distribute_reward`.
+ /// The entry is recorded under `DataKey::Spender(spender)` in persistent
+ /// storage, so it survives contract upgrades. Re-whitelisting is idempotent.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `spender` - The contract address to whitelist
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `spender` — Contract address to whitelist.
///
/// # Panics
- /// * If contract is not initialized
- /// * If admin does not match stored admin
- /// * If admin authentication fails
- /// Whitelist a caller contract so future `distribute_reward`
- /// calls from that contract's address are authorised. The
- /// spender is recorded under `DataKey::Spender(address)` in
- /// persistent storage. Re-whitelisting is allowed (idempotent).
+ ///
+ /// * `"Not initialized"` — if `DataKey::Admin` is absent.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.add_approved_spender(&admin, &course_registry_address);
+ /// ```
pub fn add_approved_spender(env: Env, admin: Address, spender: Address) {
- // 1. Fetch 'Admin' address from Instance storage
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
- // 2. Assert admin == stored_admin
if admin != stored_admin {
panic!("Unauthorized");
}
- // 3. admin.require_auth()
admin.require_auth();
- // 4. Save `true` to Persistent storage using DataKey::Spender(spender.clone())
env.storage()
.persistent()
.set(&DataKey::Spender(spender.clone()), &true);
- // 5. Emit SpenderAdded event
SpenderAdded { spender }.publish(&env);
}
- /// Toggles the pause state of the contract (emergency circuit breaker).
+ /// Toggles the global pause state of the contract.
+ ///
+ /// When paused (`status = true`), all `distribute_reward` calls panic
+ /// with `"Contract is paused"`. Admin-only circuit-breaker for incidents.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `status` - The pause status (true = paused, false = unpaused)
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `status` — `true` to pause, `false` to unpause.
///
/// # Panics
- /// * If contract is not initialized
- /// * If admin does not match stored admin
- /// * If admin authentication fails
- /// Sets the `IsPaused` flag in instance storage as a circuit
- /// breaker. Admin-only. When `IsPaused` is true,
- /// `distribute_reward` returns early with `"Contract is paused"`.
+ ///
+ /// * `"Not initialized"` — if `DataKey::Admin` is absent.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn set_pause(env: Env, admin: Address, status: bool) {
- // 1. Fetch 'Admin' address from Instance storage
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
- // 2. Assert admin == stored_admin
if admin != stored_admin {
panic!("Unauthorized");
}
- // 3. admin.require_auth()
admin.require_auth();
- // 4. Store pause status in Instance storage
env.storage().instance().set(&DataKey::IsPaused, &status);
}
- /// Distributes rewards from the pool to a learner.
+ /// Distributes reward tokens from the pool to a learner.
+ ///
+ /// The canonical payout path used by `CourseRegistry` and `QuestEngine`.
+ /// The caller must be whitelisted via `add_approved_spender`, the amount
+ /// must be strictly positive, and the contract must be unpaused.
+ /// Tokens are transferred from this contract's own balance.
///
/// # Arguments
- /// * `caller` - The spender contract address (must be whitelisted)
- /// * `learner` - The learner address to receive the reward
- /// * `amount` - The amount of tokens to transfer
+ ///
+ /// * `caller` — Whitelisted spender contract (required auth).
+ /// * `learner` — Recipient of the reward tokens.
+ /// * `amount` — Number of tokens to transfer (must be `> 0`).
///
/// # Panics
- /// * If caller authentication fails
- /// * If amount is not positive
- /// * If caller is not an authorized spender
- /// * If contract is not initialized
- /// Performs the canonical USDC payout path used by CourseRegistry.
- /// Spender must be whitelisted via `add_approved_spender`. The
- /// amount must be strictly positive. The contract must be unpaused.
- /// Funds are transferred from this contract's balance.
+ ///
+ /// * `"Contract is paused"` — if `IsPaused` is `true`.
+ /// * `"Amount must be positive"` — if `amount <= 0`.
+ /// * `"Not initialized"` — if the contract hasn't been initialized.
+ /// * `"Caller is not an authorized spender"` — if `caller` is not whitelisted.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// // registry must be whitelisted first
+ /// client.add_approved_spender(&admin, ®istry_addr);
+ /// client.distribute_reward(®istry_addr, &learner, &100_0000000i128);
+ /// ```
pub fn distribute_reward(env: Env, caller: Address, learner: Address, amount: i128) {
- // 0. Check if contract is paused
let is_paused: bool = env
.storage()
.instance()
@@ -220,23 +281,18 @@ mod contract_impl {
.unwrap_or(false);
assert!(!is_paused, "Contract is paused");
- // 1. caller.require_auth()
caller.require_auth();
- // 2. Assert amount > 0
if amount <= 0 {
panic!("Amount must be positive");
}
- // 3. Check if contract is initialized first
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.expect("Not initialized");
- // 4. Construct DataKey::Spender(caller.clone())
- // 5. Fetch the boolean from Persistent storage. Assert it is true
let is_authorized: bool = env
.storage()
.persistent()
@@ -247,13 +303,9 @@ mod contract_impl {
panic!("Caller is not an authorized spender");
}
- // 6. Initialize token::Client::new(&env, &token_id)
let token_client = token::Client::new(&env, &token_id);
-
- // 7. Call token_client.transfer(&env.current_contract_address(), &learner, &amount)
token_client.transfer(&env.current_contract_address(), &learner, &amount);
- // 8. Emit RewardDistributed event
RewardDistributed {
caller,
learner,
@@ -262,88 +314,78 @@ mod contract_impl {
.publish(&env);
}
- /// Funds the reward pool with tokens from a donor.
+ /// Tops up the reward pool by transferring tokens from a donor.
+ ///
+ /// The donor must authorize the token transfer. On success the contract's
+ /// token balance increases and [`PoolFunded`] is emitted.
///
/// # Arguments
- /// * `donor` - The address donating the tokens
- /// * `amount` - The amount of tokens to donate
+ ///
+ /// * `donor` — Address supplying the tokens (required auth).
+ /// * `amount` — Number of tokens to deposit.
///
/// # Panics
- /// * If contract is not initialized
- /// * If donor authentication fails
- /// * If token transfer fails
- /// Donor-funded top-up of the reward pool's token balance. The donor
- /// must authorize the token transfer; on success a `PoolFunded`
- /// event is published and the contract's balance increases.
+ ///
+ /// * `"Not initialized"` — if the contract hasn't been initialized.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.fund_pool(&donor, &500_0000000i128);
+ /// ```
pub fn fund_pool(env: Env, donor: Address, amount: i128) {
- // 1. donor.require_auth()
donor.require_auth();
- // 2. Fetch 'Token_Address' from Instance storage
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.expect("Not initialized");
- // 3. Initialize token::Client::new(&env, &Token_Address)
let token_client = token::Client::new(&env, &token_id);
-
- // 4. Call token_client.transfer(&donor, &env.current_contract_address(), &amount)
token_client.transfer(&donor, env.current_contract_address(), &amount);
- // 5. Emit PoolFunded event
PoolFunded { donor, amount }.publish(&env);
}
- /// Emergency sweep function allowing admin to transfer all tokens from the contract
- /// to a recovery wallet in case of a critical vulnerability.
+ /// Transfers the entire token balance to a recovery wallet. Admin-only.
+ ///
+ /// Intended for incidents requiring a full token rescue. Emits
+ /// [`EmergencySweep`] with the swept amount.
///
/// # Arguments
- /// * `admin` - The admin address (must match stored admin)
- /// * `recovery_wallet` - The address to receive the swept tokens
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `recovery_wallet` — Destination address for the swept tokens.
///
/// # Panics
- /// * If contract is not initialized
- /// * If admin does not match stored admin
- /// * If admin authentication fails
- /// Transfers the entire token balance of the contract to a
- /// designated recovery wallet. Admin-only. Emits
- /// `EmergencySweep` with the swept amount. Intended for
- /// incidents requiring a full token rescue.
+ ///
+ /// * `"Not initialized"` — if the contract hasn't been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn emergency_sweep(env: Env, admin: Address, recovery_wallet: Address) {
- // 1. admin.require_auth()
admin.require_auth();
- // 2. Fetch stored admin from Instance storage
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
- // 3. Assert admin == stored_admin
if admin != stored_admin {
panic!("Unauthorized");
}
- // 4. Fetch token address from Instance storage
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.expect("Not initialized");
- // 5. Initialize token client
let token_client = token::Client::new(&env, &token_id);
- // 6. Fetch full contract token balance
let balance = token_client.balance(&env.current_contract_address());
-
- // 7. Transfer full balance to recovery wallet
token_client.transfer(&env.current_contract_address(), &recovery_wallet, &balance);
- // 8. Emit EmergencySweep event
EmergencySweep {
admin,
recovery_wallet,
@@ -352,10 +394,20 @@ mod contract_impl {
.publish(&env);
}
- /// Upgrades the contract WASM. Only callable by the Protocol Admin.
- /// Replaces the RewardPool WASM with the supplied hash on the
- /// Soroban host. Admin-only. Emits `ContractUpgraded` on
- /// successful deployment of the new WASM.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the RewardPool WASM on the Soroban host and emits
+ /// [`ContractUpgraded`] on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract hasn't been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
diff --git a/contracts/reward-pool/src/types.rs b/contracts/reward-pool/src/types.rs
index bfd71b8..981d35f 100644
--- a/contracts/reward-pool/src/types.rs
+++ b/contracts/reward-pool/src/types.rs
@@ -1,10 +1,26 @@
+//! Shared types for the RewardPool contract.
+
use soroban_sdk::{contracttype, Address};
+/// Storage keys used by the RewardPool contract.
+///
+/// | Variant | Storage tier | Type | Description |
+/// |---------|-------------|------|-------------|
+/// | `Admin` | Instance | `Address` | Protocol admin address |
+/// | `Token` | Instance | `Address` | SAC reward-token address |
+/// | `Spender(Address)` | Persistent | `bool` | Approved spender flag |
+/// | `IsPaused` | Instance | `bool` | Global pause circuit-breaker |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Instance storage key for the protocol admin [`Address`].
Admin,
+ /// Instance storage key for the reward SAC token [`Address`].
Token,
+ /// Persistent storage key indicating whether a given [`Address`] is
+ /// an approved spender permitted to call `distribute_reward`.
Spender(Address),
+ /// Instance storage key for the global pause flag.
+ /// When `true`, `distribute_reward` panics with `"Contract is paused"`.
IsPaused,
}
diff --git a/contracts/stake-vault/src/lib.rs b/contracts/stake-vault/src/lib.rs
index ea029f8..b223544 100644
--- a/contracts/stake-vault/src/lib.rs
+++ b/contracts/stake-vault/src/lib.rs
@@ -1,24 +1,43 @@
+//! # Stake Vault Contract
+//!
+//! Token staking with a one-week lock window and a three-tier basis-points
+//! multiplier consumed by `QuestEngine` at review time.
+//!
+//! ## Operational notes
+//!
+//! * Multiplier tiers: `100` (default, 1×), `120` (≥ 100 staked, 1.2×),
+//! `200` (≥ 500 staked, 2×).
+//! * Re-staking **resets** the lock timestamp, restarting the one-week window.
+//! * After `unstake` the storage slot is deleted; subsequent `unstake` calls
+//! panic with `"No stake found"`.
+//!
+//! ## Storage layout
+//!
+//! | Key | Tier | Type | Description |
+//! |-----|------|------|-------------|
+//! | `DataKey::Admin` | Instance | `Address` | Protocol admin |
+//! | `DataKey::Token` | Instance | `Address` | Staking token |
+//! | `DataKey::UserStake(addr)` | Persistent | [`StakeInfo`] | Per-user stake record |
+
#![no_std]
+/// Basis-points multiplier for the high stake tier (≥ 500 tokens). Equals 2×.
pub const STAKE_TIER_HIGH_BPS: u32 = 200;
+/// Basis-points multiplier for the low stake tier (≥ 100 tokens). Equals 1.2×.
pub const STAKE_TIER_LOW_BPS: u32 = 120;
+/// Basis-points multiplier when the user has no qualifying stake. Equals 1×.
pub const STAKE_TIER_NONE_BPS: u32 = 100;
-// Operational notes — multiplier calculation is a 3-tier
-// lookup bound by `TIER_LOW_STAKE_BOUND` and
-// `TIER_HIGH_STAKE_BOUND`. Re-staking resets the lock
-// timestamp. Lock period is one week by default
-// (`DEFAULT_LOCK_PERIOD_SECONDS`).
+/// Minimum staked amount to reach the **high** multiplier tier.
pub const TIER_HIGH_STAKE_BOUND: i128 = 500;
+/// Minimum staked amount to reach the **low** multiplier tier.
pub const TIER_LOW_STAKE_BOUND: i128 = 100;
+/// Default stake lock period in seconds (7 days = 604 800 s).
pub const DEFAULT_LOCK_PERIOD_SECONDS: u64 = 604800;
-// Crate overview — stake lock holding and multiplier computation.
-// Provides `get_multiplier(user)` for cross-contract use by
-// QuestEngine on review-time payout calculation.
use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env};
pub mod types;
@@ -27,43 +46,72 @@ use types::{DataKey, StakeInfo};
#[contract]
pub struct StakeVault;
+/// Emitted once when the contract is successfully initialized.
#[contractevent]
pub struct StakeVaultInitialized {
+ /// Protocol admin address recorded at initialization.
#[topic]
pub admin: Address,
+ /// Staking token address recorded at initialization.
#[topic]
pub token: Address,
}
+/// Emitted each time a user successfully stakes tokens.
#[contractevent]
pub struct Staked {
+ /// User who performed the stake.
#[topic]
pub user: Address,
+ /// Additional amount staked in this call.
pub amount: i128,
+ /// New cumulative staked balance after this call.
pub total_staked: i128,
+ /// Ledger timestamp at which the lock window was (re-)started.
pub lock_timestamp: u64,
}
+/// Emitted when a user successfully unstakes their full balance.
#[contractevent]
pub struct Unstaked {
+ /// User who performed the unstake.
#[topic]
pub user: Address,
+ /// Full amount returned to the user.
pub amount: i128,
}
+/// Emitted when the contract WASM is upgraded.
#[contractevent]
pub struct ContractUpgraded {
+ /// Admin who authorized the upgrade.
#[topic]
pub admin: Address,
+ /// SHA-256 hash of the new WASM blob.
pub new_wasm_hash: BytesN<32>,
}
#[contractimpl]
impl StakeVault {
- /// Initializes the StakeVault with admin and reward token
- /// addresses and emits `StakeVaultInitialized`. Admin-only at
- /// deploy time. Re-initialization panics with
- /// `"Already initialized"`.
+ /// Initializes the StakeVault with the admin and staking token addresses.
+ ///
+ /// Stores both addresses in instance storage and emits [`StakeVaultInitialized`].
+ /// Must be called exactly once after deployment; subsequent calls panic.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Protocol admin address (required auth).
+ /// * `token` — Address of the token users will stake.
+ ///
+ /// # Panics
+ ///
+ /// * `"Already initialized"` — if `DataKey::Admin` already exists.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.initialize(&admin, &token);
+ /// ```
pub fn initialize(env: Env, admin: Address, token: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
@@ -77,9 +125,28 @@ impl StakeVault {
StakeVaultInitialized { admin, token }.publish(&env);
}
- /// Locks tokens for the configured lock period and resets the
- /// caller's `lock_timestamp` to the current ledger time. Multi-call
- /// stakes accumulate in the same `StakeInfo.amount` field.
+ /// Locks `amount` tokens for `user` and resets the one-week lock window.
+ ///
+ /// Multiple `stake` calls accumulate in `StakeInfo.amount`. The
+ /// `lock_timestamp` is always reset to the current ledger time, restarting
+ /// the full [`DEFAULT_LOCK_PERIOD_SECONDS`] window on every call.
+ ///
+ /// # Arguments
+ ///
+ /// * `user` — Address staking the tokens (required auth).
+ /// * `amount` — Tokens to lock; must be `> 0`.
+ ///
+ /// # Panics
+ ///
+ /// * `"Amount must be positive"` — if `amount <= 0`.
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// client.stake(&user, &200i128);
+ /// assert_eq!(client.get_multiplier(&user), 120); // 200 >= TIER_LOW_STAKE_BOUND
+ /// ```
pub fn stake(env: Env, user: Address, amount: i128) {
user.require_auth();
@@ -123,9 +190,22 @@ impl StakeVault {
.publish(&env);
}
- /// Releases the caller's full staked balance once the lock period
- /// has elapsed. After successful withdrawal the storage slot is
- /// cleared — subsequent `unstake` calls panic with `"No stake found"`.
+ /// Releases the caller's full staked balance once the lock period has elapsed.
+ ///
+ /// After a successful withdrawal the `DataKey::UserStake(user)` slot is
+ /// deleted from persistent storage. A subsequent `unstake` call will panic
+ /// with `"No stake found"`.
+ ///
+ /// # Arguments
+ ///
+ /// * `user` — Address withdrawing their stake (required auth).
+ ///
+ /// # Panics
+ ///
+ /// * `"No stake found"` — if `user` has no active stake record.
+ /// * `"Lock period active"` — if fewer than [`DEFAULT_LOCK_PERIOD_SECONDS`]
+ /// seconds have elapsed since `lock_timestamp`.
+ /// * `"Not initialized"` — if the contract has not been initialized.
pub fn unstake(env: Env, user: Address) {
user.require_auth();
@@ -164,10 +244,35 @@ impl StakeVault {
.publish(&env);
}
- /// Returns a basis-points multiplier based on the user's staked
- /// amount. The scheme uses three tiers: 100 (default, 1.0x),
- /// 120 (≥100 stake, 1.2x), and 200 (≥500 stake, 2.0x). Quest
- /// review paths consult this value to scale payouts.
+ /// Returns a basis-points multiplier based on the user's current staked balance.
+ ///
+ /// Tier table (basis points → effective multiplier):
+ ///
+ /// | Staked amount | BPS | Multiplier |
+ /// |---------------|-----|-----------|
+ /// | `< 100` | 100 | 1.0× |
+ /// | `≥ 100` | 120 | 1.2× |
+ /// | `≥ 500` | 200 | 2.0× |
+ ///
+ /// `QuestEngine` calls this at review time to scale learner payouts.
+ /// Returns the `STAKE_TIER_NONE_BPS` (100) default if the user has no
+ /// active stake.
+ ///
+ /// # Arguments
+ ///
+ /// * `user` — The address whose multiplier is being queried.
+ ///
+ /// # Returns
+ ///
+ /// A `u32` basis-points value: `100`, `120`, or `200`.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,ignore
+ /// assert_eq!(client.get_multiplier(&user), 100); // no stake
+ /// client.stake(&user, &500i128);
+ /// assert_eq!(client.get_multiplier(&user), 200); // high tier
+ /// ```
pub fn get_multiplier(env: Env, user: Address) -> u32 {
let stake_info: StakeInfo = env
.storage()
@@ -187,9 +292,20 @@ impl StakeVault {
}
}
- /// Replaces the StakeVault WASM with the supplied hash on the
- /// Soroban host. Admin-only. Emits `ContractUpgraded` on
- /// successful deployment.
+ /// Upgrades the contract WASM to a new hash. Only callable by the protocol admin.
+ ///
+ /// Replaces the StakeVault WASM on the Soroban host and emits
+ /// [`ContractUpgraded`] on success.
+ ///
+ /// # Arguments
+ ///
+ /// * `admin` — Must equal the stored admin (required auth).
+ /// * `new_wasm_hash` — SHA-256 hash of the replacement WASM blob.
+ ///
+ /// # Panics
+ ///
+ /// * `"Not initialized"` — if the contract has not been initialized.
+ /// * `"Unauthorized"` — if `admin ≠ stored admin`.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();
diff --git a/contracts/stake-vault/src/types.rs b/contracts/stake-vault/src/types.rs
index 615c54c..da63200 100644
--- a/contracts/stake-vault/src/types.rs
+++ b/contracts/stake-vault/src/types.rs
@@ -1,16 +1,43 @@
+//! Shared types for the StakeVault contract.
+
use soroban_sdk::{contracttype, Address};
+/// Stake record for a single user, stored in persistent storage.
+///
+/// Both fields are updated atomically on every `stake` call:
+/// * `amount` accumulates across multiple calls (multi-stake is supported).
+/// * `lock_timestamp` is **reset** on every `stake` call, restarting the
+/// one-week lock window regardless of any prior stake.
+///
+/// # Invariants
+///
+/// * `amount` is always `> 0` while the record exists in storage. The slot
+/// is deleted by `unstake`, so a missing key always means no active stake.
+/// * `lock_timestamp` is a Soroban ledger Unix timestamp in seconds.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StakeInfo {
+ /// Total tokens currently locked by this user.
pub amount: i128,
+ /// Ledger timestamp (Unix seconds) of the most recent `stake` call.
+ /// The lock expires at `lock_timestamp + DEFAULT_LOCK_PERIOD_SECONDS`.
pub lock_timestamp: u64,
}
+/// Storage keys used by the StakeVault contract.
+///
+/// | Variant | Storage tier | Type | Description |
+/// |---------|-------------|------|-------------|
+/// | `Admin` | Instance | `Address` | Protocol admin address |
+/// | `Token` | Instance | `Address` | Staking token address |
+/// | `UserStake(Address)` | Persistent | [`StakeInfo`] | Per-user stake record |
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
+ /// Instance storage key for the protocol admin [`Address`].
Admin,
+ /// Instance storage key for the staking token [`Address`].
Token,
+ /// Persistent storage key for the [`StakeInfo`] of a given user.
UserStake(Address),
}