Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

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

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

[workspace.dependencies]
Expand Down
86 changes: 85 additions & 1 deletion contracts/badge-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use types::Badge;
pub trait BadgeNFTInterface {
fn initialize(env: Env, admin: Address);
fn mint_badge(env: Env, caller: Address, learner: Address, course_id: u32);
fn revoke_badge(env: Env, admin: 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;
Expand All @@ -26,6 +27,14 @@ pub struct BadgeMinted {
pub minted_at: u64,
}

#[contractevent]
pub struct BadgeRevoked {
#[topic]
pub learner: Address,
#[topic]
pub course_id: u32,
}

#[contractevent]
pub struct ContractUpgraded {
#[topic]
Expand All @@ -41,7 +50,7 @@ mod contract_impl {
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};

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

#[contract]
pub struct BadgeNFT;
Expand Down Expand Up @@ -109,7 +118,69 @@ 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.
///
/// # 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
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()
.get(&DataKey::Admin)
.expect("Contract not initialized");
assert!(
admin == 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>.
let mut badges: Vec<Badge> = 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() {
if badge.course_id == course_id {
index_to_remove = i as u32;
found = true;
break;
}
}

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.
///
/// # 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()
Expand All @@ -119,12 +190,25 @@ mod contract_impl {
}

/// 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()
}

/// 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() {
Expand Down
54 changes: 54 additions & 0 deletions contracts/badge-nft/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,60 @@ fn test_mint_badge_timestamp_is_set() {
assert_eq!(badge.minted_at, 0);
}

// ── revoke_badge Tests ───────────────────────────────────────────────────────

#[test]
fn test_revoke_badge_success() {
let (env, client) = setup();
let registry = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&registry);
client.mint_badge(&registry, &learner, &1);

// Verify badge exists
assert_eq!(client.get_badge_count(&learner), 1);

// Revoke badge
client.revoke_badge(&registry, &learner, &1);

// Verify badge is removed
assert_eq!(client.get_badge_count(&learner), 0);
}

#[test]
fn test_revoke_badge_emits_event() {
let (env, client) = setup();
let registry = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&registry);
client.mint_badge(&registry, &learner, &1);

client.revoke_badge(&registry, &learner, &1);

let last_event = env.events().all().last().unwrap();
let expected_topic: Vec<Val> =
(Symbol::new(&env, "badge_revoked"), &learner, 1u32).into_val(&env);

assert_eq!(last_event.1, expected_topic);
}

#[test]
#[should_panic(expected = "Unauthorized: Caller is not the authorized registry")]
fn test_revoke_badge_unauthorized_caller() {
let (env, client) = setup();
let registry = Address::generate(&env);
let unauthorized_caller = Address::generate(&env);
let learner = Address::generate(&env);

client.initialize(&registry);
client.mint_badge(&registry, &learner, &1);

// Try to revoke with unauthorized caller - should panic
client.revoke_badge(&unauthorized_caller, &learner, &1);
}

// ── get_badges Tests ─────────────────────────────────────────────────────────

#[test]
Expand Down
Loading
Loading