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), }