From de7de1a1d36d6fe061b2f2fb40f07a105b332736 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Wed, 27 May 2026 09:09:16 +0100 Subject: [PATCH] Add Admin Role Separation Add Admin Role Separation --- contracts/quest_chain/src/lib.rs | 243 ++++++++++++++++++++++-------- contracts/quest_chain/src/test.rs | 100 +++++++++++- 2 files changed, 274 insertions(+), 69 deletions(-) diff --git a/contracts/quest_chain/src/lib.rs b/contracts/quest_chain/src/lib.rs index d937bd1..8a75174 100644 --- a/contracts/quest_chain/src/lib.rs +++ b/contracts/quest_chain/src/lib.rs @@ -27,8 +27,8 @@ pub struct Quest { pub reward: i128, pub status: QuestStatus, pub prerequisites: Vec, // Quest IDs that must be completed first - pub branches: Vec, // Alternative quest IDs (for branching paths) - pub checkpoint: bool, // Whether this quest saves progress + pub branches: Vec, // Alternative quest IDs (for branching paths) + pub checkpoint: bool, // Whether this quest saves progress } #[contracttype] @@ -41,7 +41,7 @@ pub struct QuestChain { pub quests: Vec, pub total_reward: i128, pub start_time: Option, // None = no time limit - pub end_time: Option, // None = no time limit + pub end_time: Option, // None = no time limit pub created_at: u64, pub active: bool, } @@ -51,8 +51,8 @@ pub struct QuestChain { pub struct PlayerProgress { pub player: Address, pub chain_id: u32, - pub completed_quests: Vec, // Quest IDs completed - pub current_quest: Option, // Currently active quest ID + pub completed_quests: Vec, // Quest IDs completed + pub current_quest: Option, // Currently active quest ID pub checkpoint_quest: Option, // Last checkpoint quest ID pub start_time: u64, pub completion_time: Option, // None if not completed @@ -73,7 +73,7 @@ pub struct CompletionRecord { #[contracttype] #[derive(Clone, Debug)] pub struct ChainConfig { - pub admin: Address, + pub owner: Address, pub reward_token: Option
, // Optional reward token for distributing rewards pub max_chains: u32, pub min_quests_per_chain: u32, @@ -88,14 +88,16 @@ pub struct ChainConfig { #[contracttype] pub enum DataKey { - Config, // ChainConfig - ChainCounter, // u32 - Chain(u32), // QuestChain + Config, // ChainConfig + ChainCounter, // u32 + Chain(u32), // QuestChain PlayerProgress(Address, u32), // PlayerProgress - (player, chain_id) - CompletionLeaderboard(u32), // Vec - sorted by duration (fastest first) - ChainCompletions(u32), // u32 - total completions for chain - RewardPool(u32), // i128 - reward pool for chain (if using token rewards) + CompletionLeaderboard(u32), // Vec - sorted by duration (fastest first) + ChainCompletions(u32), // u32 - total completions for chain + RewardPool(u32), // i128 - reward pool for chain (if using token rewards) PendingRewards(Address, u32), // i128 - pending rewards for player in chain + Manager(Address), // bool - manager role assignment + Moderator(Address), // bool - moderator role assignment } // @@ -138,17 +140,17 @@ impl QuestChainContract { /// Initialize the quest chain contract /// /// # Arguments - /// * `admin` - Contract administrator + /// * `owner` - Contract owner /// * `reward_token` - Optional reward token address for distributing rewards - pub fn initialize(env: Env, admin: Address, reward_token: Option
) { - admin.require_auth(); + pub fn initialize(env: Env, owner: Address, reward_token: Option
) { + owner.require_auth(); if env.storage().persistent().has(&DataKey::Config) { panic!("Already initialized"); } let config = ChainConfig { - admin, + owner, reward_token, max_chains: DEFAULT_MAX_CHAINS, min_quests_per_chain: DEFAULT_MIN_QUESTS, @@ -156,7 +158,9 @@ impl QuestChainContract { }; env.storage().persistent().set(&DataKey::Config, &config); - env.storage().persistent().set(&DataKey::ChainCounter, &0u32); + env.storage() + .persistent() + .set(&DataKey::ChainCounter, &0u32); } // ───────────── CHAIN CREATION ───────────── @@ -164,7 +168,7 @@ impl QuestChainContract { /// Create a new quest chain /// /// # Arguments - /// * `admin` - Chain creator (must be admin) + /// * `admin` - Chain creator (must be owner or manager) /// * `title` - Chain title /// * `description` - Chain description /// * `quests` - Vector of quests in the chain @@ -180,7 +184,7 @@ impl QuestChainContract { end_time: Option, ) -> u32 { admin.require_auth(); - Self::assert_admin(&env, &admin); + Self::assert_owner_or_manager(&env, &admin); let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); @@ -225,8 +229,12 @@ impl QuestChainContract { active: true, }; - env.storage().persistent().set(&DataKey::ChainCounter, &counter); - env.storage().persistent().set(&DataKey::Chain(counter), &chain); + env.storage() + .persistent() + .set(&DataKey::ChainCounter, &counter); + env.storage() + .persistent() + .set(&DataKey::Chain(counter), &chain); env.storage() .persistent() .set(&DataKey::ChainCompletions(counter), &0u32); @@ -300,9 +308,10 @@ impl QuestChainContract { path_taken: Vec::new(&env), }; - env.storage() - .persistent() - .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + env.storage().persistent().set( + &DataKey::PlayerProgress(player.clone(), chain_id), + &progress, + ); } /// Complete a quest in a chain @@ -349,7 +358,7 @@ impl QuestChainContract { let prerequisites_met = Self::are_prerequisites_met(&progress, &quest.prerequisites); let branch_unlocked = Self::is_quest_unlocked_by_branch(&progress, &quest.branches); let is_current = progress.current_quest == Some(quest_id); - + if !prerequisites_met && !branch_unlocked && !is_current { panic!("Quest not unlocked"); } @@ -367,18 +376,17 @@ impl QuestChainContract { .persistent() .get(&DataKey::PendingRewards(player.clone(), chain_id)) .unwrap_or(0); - env.storage() - .persistent() - .set(&DataKey::PendingRewards(player.clone(), chain_id), &(current_pending + quest.reward)); + env.storage().persistent().set( + &DataKey::PendingRewards(player.clone(), chain_id), + &(current_pending + quest.reward), + ); } // Save checkpoint if this quest is a checkpoint if quest.checkpoint { progress.checkpoint_quest = Some(quest_id); - env.events().publish( - (PROGRESS_CHECKPOINT, player.clone()), - (chain_id, quest_id), - ); + env.events() + .publish((PROGRESS_CHECKPOINT, player.clone()), (chain_id, quest_id)); } // Determine next quest(s) @@ -409,9 +417,10 @@ impl QuestChainContract { ); } - env.storage() - .persistent() - .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + env.storage().persistent().set( + &DataKey::PlayerProgress(player.clone(), chain_id), + &progress, + ); env.events().publish( (QUEST_COMPLETED, player.clone()), @@ -493,19 +502,19 @@ impl QuestChainContract { .persistent() .get(&DataKey::PendingRewards(player.clone(), chain_id)) .unwrap_or(0); - env.storage() - .persistent() - .set(&DataKey::PendingRewards(player.clone(), chain_id), &(current_pending - reward_lost)); + env.storage().persistent().set( + &DataKey::PendingRewards(player.clone(), chain_id), + &(current_pending - reward_lost), + ); } - env.storage() - .persistent() - .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); - - env.events().publish( - (CHAIN_RESET, player.clone()), - (chain_id, checkpoint_id), + env.storage().persistent().set( + &DataKey::PlayerProgress(player.clone(), chain_id), + &progress, ); + + env.events() + .publish((CHAIN_RESET, player.clone()), (chain_id, checkpoint_id)); } /// Reset entire chain progress for a player @@ -539,7 +548,8 @@ impl QuestChainContract { .remove(&DataKey::PendingRewards(player.clone(), chain_id)); } - env.events().publish((CHAIN_RESET, player.clone()), (chain_id, 0u32)); + env.events() + .publish((CHAIN_RESET, player.clone()), (chain_id, 0u32)); } // ───────────── VIEW FUNCTIONS ───────────── @@ -571,7 +581,9 @@ impl QuestChainContract { .get(&DataKey::CompletionLeaderboard(chain_id)) .unwrap_or(Vec::new(&env)); - let actual_limit = limit.min(MAX_LEADERBOARD_ENTRIES).min(leaderboard.len() as u32); + let actual_limit = limit + .min(MAX_LEADERBOARD_ENTRIES) + .min(leaderboard.len() as u32); let mut result = Vec::new(&env); for i in 0..actual_limit { @@ -669,7 +681,7 @@ impl QuestChainContract { // ───────────── ADMIN FUNCTIONS ───────────── - /// Update chain configuration (admin only) + /// Update chain configuration (owner only) pub fn update_config( env: Env, admin: Address, @@ -678,10 +690,9 @@ impl QuestChainContract { max_quests: Option, ) { admin.require_auth(); - Self::assert_admin(&env, &admin); + Self::assert_owner(&env, &admin); - let mut config: ChainConfig = - env.storage().persistent().get(&DataKey::Config).unwrap(); + let mut config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); if let Some(max) = max_chains { config.max_chains = max; @@ -696,10 +707,10 @@ impl QuestChainContract { env.storage().persistent().set(&DataKey::Config, &config); } - /// Activate or deactivate a chain (admin only) + /// Activate or deactivate a chain (owner, manager, or moderator only) pub fn set_chain_active(env: Env, admin: Address, chain_id: u32, active: bool) { admin.require_auth(); - Self::assert_admin(&env, &admin); + Self::assert_owner_manager_or_moderator(&env, &admin); let mut chain: QuestChain = env .storage() @@ -708,21 +719,22 @@ impl QuestChainContract { .unwrap(); chain.active = active; - env.storage().persistent().set(&DataKey::Chain(chain_id), &chain); + env.storage() + .persistent() + .set(&DataKey::Chain(chain_id), &chain); } - /// Set reward token for the contract (admin only) + /// Set reward token for the contract (owner only) pub fn set_reward_token(env: Env, admin: Address, reward_token: Option
) { admin.require_auth(); - Self::assert_admin(&env, &admin); + Self::assert_owner(&env, &admin); - let mut config: ChainConfig = - env.storage().persistent().get(&DataKey::Config).unwrap(); + let mut config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); config.reward_token = reward_token; env.storage().persistent().set(&DataKey::Config, &config); } - /// Fund reward pool for a chain (admin only) + /// Fund reward pool for a chain (owner only) /// Admin must first approve the contract to spend tokens /// /// # Arguments @@ -731,7 +743,7 @@ impl QuestChainContract { /// * `amount` - Amount of tokens to add to reward pool pub fn fund_reward_pool(env: Env, admin: Address, chain_id: u32, amount: i128) { admin.require_auth(); - Self::assert_admin(&env, &admin); + Self::assert_owner(&env, &admin); if amount <= 0 { panic!("Amount must be positive"); @@ -763,15 +775,111 @@ impl QuestChainContract { ); } + /// Assign manager role to an address (owner only) + pub fn assign_manager(env: Env, owner: Address, manager: Address) { + owner.require_auth(); + Self::assert_owner(&env, &owner); + + env.storage() + .persistent() + .set(&DataKey::Manager(manager.clone()), &true); + + env.events() + .publish((symbol_short!("mgr_add"), owner), manager); + } + + /// Revoke manager role from an address (owner only) + pub fn revoke_manager(env: Env, owner: Address, manager: Address) { + owner.require_auth(); + Self::assert_owner(&env, &owner); + + env.storage() + .persistent() + .remove(&DataKey::Manager(manager.clone())); + + env.events() + .publish((symbol_short!("mgr_rm"), owner), manager); + } + + /// Assign moderator role to an address (owner only) + pub fn assign_moderator(env: Env, owner: Address, moderator: Address) { + owner.require_auth(); + Self::assert_owner(&env, &owner); + + env.storage() + .persistent() + .set(&DataKey::Moderator(moderator.clone()), &true); + + env.events() + .publish((symbol_short!("mod_add"), owner), moderator); + } + + /// Revoke moderator role from an address (owner only) + pub fn revoke_moderator(env: Env, owner: Address, moderator: Address) { + owner.require_auth(); + Self::assert_owner(&env, &owner); + + env.storage() + .persistent() + .remove(&DataKey::Moderator(moderator.clone())); + + env.events() + .publish((symbol_short!("mod_rm"), owner), moderator); + } + + /// Check whether an address has manager privileges + pub fn is_manager(env: Env, user: Address) -> bool { + Self::is_owner(&env, &user) || Self::has_manager_role(&env, &user) + } + + /// Check whether an address has moderator privileges + pub fn is_moderator(env: Env, user: Address) -> bool { + Self::has_moderator_role(&env, &user) + } + // ───────────── INTERNAL HELPERS ───────────── - fn assert_admin(env: &Env, user: &Address) { + fn assert_owner(env: &Env, user: &Address) { let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); - if config.admin != *user { - panic!("Admin only"); + if config.owner != *user { + panic!("Owner only"); + } + } + + fn assert_owner_or_manager(env: &Env, user: &Address) { + if !Self::is_owner(env, user) && !Self::has_manager_role(env, user) { + panic!("Manager only"); + } + } + + fn assert_owner_manager_or_moderator(env: &Env, user: &Address) { + if !Self::is_owner(env, user) + && !Self::has_manager_role(env, user) + && !Self::has_moderator_role(env, user) + { + panic!("Manager or moderator only"); } } + fn is_owner(env: &Env, user: &Address) -> bool { + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + config.owner == *user + } + + fn has_manager_role(env: &Env, user: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Manager(user.clone())) + .unwrap_or(false) + } + + fn has_moderator_role(env: &Env, user: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Moderator(user.clone())) + .unwrap_or(false) + } + fn validate_quest_chain(env: &Env, quests: &Vec) { // Check for duplicate quest IDs let mut seen_ids = Vec::new(env); @@ -852,7 +960,11 @@ impl QuestChainContract { false } - fn get_next_quest(chain: &QuestChain, progress: &PlayerProgress, completed_id: u32) -> Option { + fn get_next_quest( + chain: &QuestChain, + progress: &PlayerProgress, + completed_id: u32, + ) -> Option { let completed_quest = Self::get_quest_by_id(chain, completed_id); if completed_quest.is_none() { return None; @@ -874,7 +986,8 @@ impl QuestChainContract { { // Check if prerequisites are met or if it's unlocked by branch let prereqs_met = Self::are_prerequisites_met(progress, &other_quest.prerequisites); - let branch_unlocked = Self::is_quest_unlocked_by_branch(progress, &other_quest.branches); + let branch_unlocked = + Self::is_quest_unlocked_by_branch(progress, &other_quest.branches); if prereqs_met || branch_unlocked { return Some(other_quest.id); } diff --git a/contracts/quest_chain/src/test.rs b/contracts/quest_chain/src/test.rs index ca90091..dd4641d 100644 --- a/contracts/quest_chain/src/test.rs +++ b/contracts/quest_chain/src/test.rs @@ -106,7 +106,7 @@ fn test_initialization() { let (client, admin) = setup_contract(&env); let config = client.get_config(); - assert_eq!(config.admin, admin); + assert_eq!(config.owner, admin); assert_eq!(config.max_chains, DEFAULT_MAX_CHAINS); assert_eq!(config.min_quests_per_chain, DEFAULT_MIN_QUESTS); assert_eq!(config.max_quests_per_chain, DEFAULT_MAX_QUESTS); @@ -119,7 +119,7 @@ fn test_double_initialization() { env.mock_all_auths(); let (client, admin) = setup_contract(&env); - client.initialize(&admin); + client.initialize(&admin, &None); } #[test] @@ -734,7 +734,7 @@ fn test_admin_functions() { } #[test] -#[should_panic(expected = "Admin only")] +#[should_panic(expected = "Owner only")] fn test_unauthorized_admin_action() { let env = Env::default(); env.mock_all_auths(); @@ -745,6 +745,98 @@ fn test_unauthorized_admin_action() { client.update_config(&non_admin, &Some(500u32), &None, &None); } +#[test] +fn test_owner_can_assign_and_revoke_manager() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, owner) = setup_contract(&env); + let manager = Address::generate(&env); + let quests = create_test_quests(&env); + + assert!(!client.is_manager(&manager)); + client.assign_manager(&owner, &manager); + assert!(client.is_manager(&manager)); + + let chain_id = client.create_chain( + &manager, + &Symbol::new(&env, "Managed"), + &Symbol::new(&env, "Created by manager"), + &quests, + &None, + &None, + ); + + client.set_chain_active(&manager, &chain_id, &false); + assert!(!client.get_chain(&chain_id).active); + + client.revoke_manager(&owner, &manager); + assert!(!client.is_manager(&manager)); +} + +#[test] +#[should_panic(expected = "Owner only")] +fn test_only_owner_can_assign_manager() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _owner) = setup_contract(&env); + let non_owner = Address::generate(&env); + let manager = Address::generate(&env); + + client.assign_manager(&non_owner, &manager); +} + +#[test] +#[should_panic(expected = "Manager only")] +fn test_revoked_manager_cannot_create_chain() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, owner) = setup_contract(&env); + let manager = Address::generate(&env); + let quests = create_test_quests(&env); + + client.assign_manager(&owner, &manager); + client.revoke_manager(&owner, &manager); + + client.create_chain( + &manager, + &Symbol::new(&env, "Revoked"), + &Symbol::new(&env, "Should fail"), + &quests, + &None, + &None, + ); +} + +#[test] +fn test_moderator_can_manage_but_not_create_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, owner) = setup_contract(&env); + let moderator = Address::generate(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &owner, + &Symbol::new(&env, "Moderated"), + &Symbol::new(&env, "Managed by moderator"), + &quests, + &None, + &None, + ); + + client.assign_moderator(&owner, &moderator); + assert!(client.is_moderator(&moderator)); + + client.set_chain_active(&moderator, &chain_id, &false); + assert!(!client.get_chain(&chain_id).active); +} + #[test] #[should_panic(expected = "Quest already completed")] fn test_complete_quest_twice() { @@ -836,7 +928,7 @@ fn test_pending_rewards_tracking() { // Complete quest 1 client.complete_quest(&player, &chain_id, &1); - + // Check pending rewards let pending = client.get_pending_rewards(&player, &chain_id); assert_eq!(pending, 100); // Quest 1 reward