Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Soroban (Stellar) smart contracts for the Orivex learning-and-rewards protocol.
| `stake-vault` | Token staking + lock + multiplier accessor |
| `governance` | Badge-weighted proposal lifecycle |
| `quest-engine` | Build & Explore quests, submissions, batch review, refunds |
| `common` | Shared TTL constants and `bump_persistent` helper |

## Build

Expand All @@ -27,3 +28,90 @@ stellar contract build
cd contracts
cargo test
```

## Storage TTL Semantics

Soroban persistent storage entries have a live-until ledger (TTL). Without
intervention they expire silently, causing learner badges, progress records,
and other state to disappear. The Orivex protocol follows a **bump-on-touch**
policy to prevent this.

### Constants (`contracts/common/src/lib.rs`)

| Constant | Value | Approximate real time |
|---|---|---|
| `LEDGER_BUMP_PERSISTENT` | 535,000 ledgers | ≈ 30 days at 5 s/ledger |
| `LEDGER_THRESHOLD_PERSISTENT` | 517,000 ledgers | Bump only triggers when remaining TTL drops below this |

The threshold is set 18,000 ledgers (≈ 25 hours) below the bump target. The
Soroban host only writes a new TTL when the current one is below the threshold,
so repeated calls on a hot key incur no extra fee.

### Policy: bump on every touch

Every contract function that performs a persistent storage `get` or `set`
calls `bump_persistent(&env, &key)` immediately afterwards. This resets the
key's live-until ledger to `current_ledger + LEDGER_BUMP_PERSISTENT`.

```rust
use orivex_common::bump_persistent;

// after a persistent write:
env.storage().persistent().set(&DataKey::Course(id), &course);
bump_persistent(&env, &DataKey::Course(id));

// after a persistent read:
let course: Course = env.storage().persistent()
.get(&DataKey::Course(id))
.expect("Course not found");
bump_persistent(&env, &DataKey::Course(id));
```

Keys that may not exist (e.g. first-time reads of optional state) are guarded
with `has()` before bumping to avoid charging fees for absent entries:

```rust
if env.storage().persistent().has(&key) {
bump_persistent(&env, &key);
}
```

### Covered persistent keys

| Contract | Persistent key(s) | Bump sites |
|---|---|---|
| `course-registry` | `Course(u32)`, `Progress(Address, u32)` | `create_course`, `update_metadata`, `enroll`, `set_course_status`, `is_course_finished`, `get_course`, `get_progress`, `transfer_ownership`, `complete_module` |
| `badge-nft` | `UserBadges(Address)` | `mint_badge`, `revoke_badge`, `get_badges` (transitively covers `get_badge_count`, `has_badge`) |
| `reward-pool` | `Spender(Address)` | `add_approved_spender`, `distribute_reward` |
| `stake-vault` | `UserStake(Address)` | `stake`, `unstake`, `get_multiplier` |
| `governance` | `Proposal(u32)`, `UserVote(Address, u32)` | `get_proposal`, `cast_vote`, `cancel_proposal`, `execute_proposal` |
| `quest-engine` | `Quest(u32)`, `Submission(Address, u32)` | `create_build_quest`, `create_explore_quest`, `get_quest`, `submit_proof`, `get_submission`, `review_submission`, `refund_quest`, `batch_review_submissions`, `verify_explore_quest` |

Instance storage (`Admin`, `Token`, etc.) is not listed above because
Soroban automatically ties instance storage TTL to the contract instance
itself, which is managed separately via `extend_ttl` on the instance.

### Audit test

`contracts/common/src/ttl_audit_test.rs` contains six tests — one per
contract — that:

1. Write a persistent key through the contract's public API.
2. Fast-forward the ledger by **100,000 sequences** using
`env.ledger().with_mut(|li| li.sequence_number += 100_000)`.
3. Read the key back and assert its value is unchanged.

Run the audit tests with:

```
cd contracts
cargo test ttl_audit
```

### Fee considerations

Each `extend_ttl` call is metered by the Soroban host. The threshold guard
(`LEDGER_THRESHOLD_PERSISTENT = 517,000`) prevents a bump on entries whose
TTL is already healthy, which keeps the overhead to a single additional host
function call only when genuinely needed (roughly once every ≈ 25 hours of
continuous use on a hot key).
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[workspace]
members = [
"common",
"course-registry",
"quest-engine",
"reward-pool",
Expand Down
1 change: 1 addition & 0 deletions contracts/badge-nft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ doctest = false

[dependencies]
soroban-sdk = { workspace = true }
orivex-common = { path = "../common" }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
Expand Down
64 changes: 17 additions & 47 deletions contracts/badge-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use soroban_sdk::{contractclient, contractevent, Address, Env, Vec};
pub mod types;
use types::Badge;

use orivex_common::bump_persistent;

// `#[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
Expand Down Expand Up @@ -63,6 +65,7 @@ mod contract_impl {

use crate::types::{Badge, DataKey};
use crate::{BadgeMinted, BadgeRevoked, ContractUpgraded};
use orivex_common::bump_persistent;

#[contract]
pub struct BadgeNFT;
Expand All @@ -74,9 +77,6 @@ mod contract_impl {
///
/// # Panics
/// * If contract is already initialized
/// Bound-checked initializer used by CourseRegistry to deploy
/// this contract. Subsequent calls panic via the `Already initialized`
/// guard.
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
Expand All @@ -91,10 +91,6 @@ mod contract_impl {
/// * 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.
pub fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32) {
caller.require_auth();

Expand All @@ -114,6 +110,9 @@ mod contract_impl {
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env));
// Bump after read (key may not exist yet on first mint — extend_ttl
// is a no-op for missing keys, so this is safe).
bump_persistent(&env, &badges_key);

for existing_badge in badges.iter() {
if existing_badge.course_id == course_id {
Expand All @@ -128,6 +127,7 @@ mod contract_impl {
};
badges.push_back(new_badge);
env.storage().persistent().set(&badges_key, &badges);
bump_persistent(&env, &badges_key);

BadgeMinted {
learner,
Expand All @@ -140,17 +140,9 @@ mod contract_impl {
/// Revokes a Soulbound Token (badge) from a learner's address.
/// Only the official protocol registry can trigger this for fraud prevention.
///
/// # 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
///
/// # 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).
pub fn revoke_badge(env: Env, admin: Address, learner: Address, course_id: u32) {
// 1. admin.require_auth()
admin.require_auth();
Expand All @@ -175,6 +167,7 @@ mod contract_impl {
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env));
bump_persistent(&env, &badges_key);

// 5. Find the badge with course_id and remove it.
let mut found = false;
Expand All @@ -190,6 +183,7 @@ mod contract_impl {
if found {
badges.remove(index_to_remove);
env.storage().persistent().set(&badges_key, &badges);
bump_persistent(&env, &badges_key);

// 6. Emit BadgeRevoked event.
BadgeRevoked { learner, course_id }.publish(&env);
Expand All @@ -198,48 +192,27 @@ mod contract_impl {

/// Returns all badges for a specific learner.
///
/// # Arguments
/// * `learner` - The learner address
///
/// # 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.
/// Returns empty vector if learner has no badges.
pub fn get_badges(env: Env, learner: Address) -> Vec<Badge> {
let badges_key = DataKey::UserBadges(learner);
env.storage()
let badges: Vec<Badge> = env
.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env))
.unwrap_or_else(|| Vec::new(&env));
if env.storage().persistent().has(&badges_key) {
bump_persistent(&env, &badges_key);
}
badges
}

/// Returns the count of badges for a specific learner.
///
/// # Arguments
/// * `learner` - The learner address
///
/// # 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.
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.
///
/// # Arguments
/// * `learner` - The learner address
/// * `course_id` - The course ID to check
///
/// # 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`.
pub fn has_badge(env: Env, learner: Address, course_id: u32) -> bool {
let badges = Self::get_badges(env, learner);
for badge in badges.iter() {
Expand All @@ -251,9 +224,6 @@ mod contract_impl {
}

/// 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.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();

Expand Down
24 changes: 24 additions & 0 deletions contracts/common/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "orivex-common"
version = "0.1.0"
edition = "2021"
authors = ["Orivex Team"]
description = "Shared TTL constants and storage helpers for the Orivex protocol"
license = "MIT"
repository = "https://github.com/Kqirox/orivex-contracts"

[lib]
crate-type = ["lib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
badge-nft = { path = "../badge-nft", features = ["testutils", "contract"] }
course-registry = { path = "../course-registry" }
governance = { path = "../governance" }
quest-engine = { path = "../quest-engine" }
reward-pool = { path = "../reward-pool", features = ["testutils", "contract"] }
stake-vault = { path = "../stake-vault" }
74 changes: 74 additions & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#![no_std]
//! Shared TTL constants and bump-on-touch helpers for the Orivex protocol.
//!
//! # Storage TTL Semantics
//!
//! Soroban persistent storage entries have a live-until ledger (TTL) after
//! which they expire and are silently removed. The Orivex protocol follows a
//! **bump-on-touch** policy: every function that reads **or** writes a
//! persistent key must extend its TTL so that active protocol data never
//! vanishes between user interactions.
//!
//! ## Constants
//! | Constant | Value | Approx. real time |
//! |---|---|---|
//! | `LEDGER_BUMP_PERSISTENT` | 535,000 ledgers | ≈ 30 days at 5 s/ledger |
//! | `LEDGER_THRESHOLD_PERSISTENT` | 517,000 ledgers | Bump triggers when TTL < this |
//!
//! The threshold is set 18,000 ledgers (≈25 h) below the bump target so that
//! storage is only extended when it is genuinely close to expiry, which keeps
//! per-call fee overhead minimal.
//!
//! ## Usage
//! ```ignore
//! use orivex_common::bump_persistent;
//!
//! // after any persistent read or write:
//! bump_persistent(&env, &DataKey::Course(id));
//! ```

use soroban_sdk::Env;

/// Target TTL added when bumping a persistent storage entry.
///
/// 535,000 ledgers ≈ 30 days at the Soroban default of 5 seconds per ledger.
pub const LEDGER_BUMP_PERSISTENT: u32 = 535_000;

/// Minimum remaining TTL before a bump is applied.
///
/// Set 18,000 ledgers (≈ 25 hours) below `LEDGER_BUMP_PERSISTENT` so that
/// redundant bumps on hot entries are cheap — the host only performs a write
/// if the current TTL is below this threshold.
pub const LEDGER_THRESHOLD_PERSISTENT: u32 = 517_000;

/// Extend the TTL of a persistent storage key if its remaining live-until
/// ledger falls below [`LEDGER_THRESHOLD_PERSISTENT`].
///
/// Call this after every persistent `get`, `set`, or `has` that touches a
/// key whose data must outlive a single session. The function is a no-op
/// when the key's TTL already exceeds the threshold, so calling it
/// unconditionally is safe and incurs no extra fee unless an actual bump is
/// required.
///
/// # Arguments
/// * `env` – The Soroban [`Env`] for the current invocation.
/// * `key` – A reference to the storage key whose TTL should be extended.
/// The key type must implement `soroban_sdk::Val` (i.e. be a
/// `#[contracttype]`).
///
/// # Example
/// ```ignore
/// bump_persistent(&env, &DataKey::UserBadges(learner.clone()));
/// ```
pub fn bump_persistent<K>(env: &Env, key: &K)
where
K: soroban_sdk::Val,
{
env.storage()
.persistent()
.extend_ttl(key, LEDGER_THRESHOLD_PERSISTENT, LEDGER_BUMP_PERSISTENT);
}

#[cfg(test)]
mod ttl_audit_test;

Loading
Loading