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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
workspaces: "./contracts"

- name: Install Stellar CLI
uses: stellar/stellar-cli@v23.0.1
uses: stellar/stellar-cli@v23.3.0

- name: Check formatting
run: cargo fmt --all -- --check
Expand Down
7 changes: 0 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ members = [
"contracts/course-registry",
"contracts/reward-pool",
"contracts/quest-engine",
"contracts/governance",
"contracts/stake-vault"
"contracts/badge-nft",
"contracts/governance"
]

Expand Down
1 change: 0 additions & 1 deletion contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ resolver = "2"

[workspace.dependencies]
soroban-sdk = "23"

[profile.release]
overflow-checks = true
5 changes: 0 additions & 5 deletions contracts/badge-nft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,3 @@ soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }

[features]
default = ["contract"]
contract = []
testutils = ["soroban-sdk/testutils"]
255 changes: 119 additions & 136 deletions contracts/badge-nft/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
#![no_std]
use soroban_sdk::{contractclient, contractevent, Address, Env, Vec};
use soroban_sdk::{contract, contractevent, contractimpl, 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.
#[contractclient(name = "BadgeNFTClient")]
pub trait BadgeNFTInterface {
fn initialize(env: Env, admin: Address);
fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32);
fn get_badges(env: Env, learner: Address) -> Vec<Badge>;
fn get_badge_count(env: Env, learner: Address) -> u32;
fn has_badge(env: Env, learner: Address, course_id: u32) -> bool;
}
use types::{Badge, DataKey};

#[contract]
pub struct BadgeNFT;

#[contractevent]
pub struct BadgeMinted {
Expand All @@ -26,140 +16,133 @@ pub struct BadgeMinted {
pub minted_at: u64,
}

#[contractevent]
pub struct ContractUpgraded {
#[topic]
pub admin: Address,
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.
#[cfg(feature = "contract")]
mod contract_impl {
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};

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

#[contract]
pub struct BadgeNFT;

#[contractimpl]
impl BadgeNFT {
/// Initializes the BadgeNFT contract with the authorized registry address.
/// Must be called once upon deployment.
///
/// # Panics
/// * If contract is already initialized
pub fn initialize(env: Env, admin: Address) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);
#[contractimpl]
impl BadgeNFT {
/// Initializes the BadgeNFT contract with the authorized registry address.
/// Must be called once upon deployment.
///
/// # Arguments
/// * `admin` - The CourseRegistry contract address authorized to mint badges
///
/// # Panics
/// * If contract is already initialized
pub fn initialize(env: Env, admin: Address) {
// 1. Check if already initialized
if env.storage().instance().has(&DataKey::Admin) {
panic!("Already initialized");
}

/// Mints a Soulbound Token (badge) directly to the learner's address.
/// Only the official protocol registry can trigger this.
///
/// # Panics
/// * If caller authentication fails
/// * If caller is not the authorized registry
/// * If learner already has a badge for this course_id (duplicate minting)
pub fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32) {
caller.require_auth();

let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Contract not initialized");
assert!(
caller == stored_admin,
"Unauthorized: Caller is not the authorized registry"
);

let badges_key = DataKey::UserBadges(learner.clone());
let mut badges: Vec<Badge> = env
.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env));

for existing_badge in badges.iter() {
if existing_badge.course_id == course_id {
panic!("Badge for this course already exists");
}
}
// 2. Store admin (registry) in Instance storage
env.storage().instance().set(&DataKey::Admin, &admin);
}

let minted_at = env.ledger().timestamp();
let new_badge = Badge {
course_id,
minted_at,
};
badges.push_back(new_badge);
env.storage().persistent().set(&badges_key, &badges);

BadgeMinted {
learner,
course_id,
minted_at,
/// Mints a Soulbound Token (badge) directly to the learner's address.
/// Only the official protocol registry can trigger this.
///
/// # Arguments
/// * `caller` - The caller address (must be the authorized registry)
/// * `learner` - The learner address to receive the badge
/// * `course_id` - The course ID for which the badge is being minted
///
/// # Panics
/// * If caller authentication fails
/// * If caller is not the authorized registry
/// * If learner already has a badge for this course_id (duplicate minting)
pub fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32) {
// 1. caller.require_auth()
caller.require_auth();

// 2. Fetch 'Admin' (Registry) address from Instance storage. Assert caller == Admin.
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Contract not initialized");
assert!(
caller == stored_admin,
"Unauthorized: Caller is not the authorized registry"
);

// 3. Construct DataKey::UserBadges(learner).
let badges_key = DataKey::UserBadges(learner.clone());

// 4. Fetch existing Vec<Badge> or initialize empty Vec.
let mut badges: Vec<Badge> = env
.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env));

// 5. Check if badge with course_id exists (prevent duplicates).
for existing_badge in badges.iter() {
if existing_badge.course_id == course_id {
panic!("Badge for this course already exists");
}
.publish(&env);
}

/// Returns all badges for a specific learner.
pub fn get_badges(env: Env, learner: Address) -> Vec<Badge> {
let badges_key = DataKey::UserBadges(learner);
env.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env))
// 6. Push new Badge to Vec and save to Persistent storage.
let minted_at = env.ledger().timestamp();
let new_badge = Badge {
course_id,
minted_at,
};

badges.push_back(new_badge);
env.storage().persistent().set(&badges_key, &badges);

// 7. Emit BadgeMinted event.
BadgeMinted {
learner,
course_id,
minted_at,
}
.publish(&env);
}

/// Returns the count of badges for a specific learner.
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.
pub fn has_badge(env: Env, learner: Address, course_id: u32) -> bool {
let badges = Self::get_badges(env, learner);
for badge in badges.iter() {
if badge.course_id == course_id {
return true;
}
}
false
}

/// Upgrades the contract WASM. Only callable by the Protocol Admin.
pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) {
admin.require_auth();

let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("Not initialized");
assert!(admin == stored_admin, "Unauthorized");
/// 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.
pub fn get_badges(env: Env, learner: Address) -> Vec<Badge> {
let badges_key = DataKey::UserBadges(learner);
env.storage()
.persistent()
.get(&badges_key)
.unwrap_or_else(|| Vec::new(&env))
}

env.deployer()
.update_current_contract_wasm(new_wasm_hash.clone());
/// Returns the count of badges for a specific learner.
///
/// # Arguments
/// * `learner` - The learner address
///
/// # Returns
/// Number of badges the learner owns.
pub fn get_badge_count(env: Env, learner: Address) -> u32 {
let badges = Self::get_badges(env, learner);
badges.len()
}

ContractUpgraded {
admin,
new_wasm_hash,
/// 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.
pub fn has_badge(env: Env, learner: Address, course_id: u32) -> bool {
let badges = Self::get_badges(env, learner);
for badge in badges.iter() {
if badge.course_id == course_id {
return true;
}
.publish(&env);
}
false
}
}

// Re-export the struct so tests can use `badge_nft::BadgeNFT` for registration.
#[cfg(feature = "contract")]
pub use contract_impl::BadgeNFT;

mod test;
Loading