From 94cffb6a0fca42d76e863f6edf4989077a93b7d4 Mon Sep 17 00:00:00 2001 From: ReinaMaze Date: Thu, 29 Jan 2026 11:21:36 +0100 Subject: [PATCH] feat: implement batch opperations support. --- contracts/Cargo.lock | 1 + contracts/access_control/Cargo.toml | 1 + .../access_control/src/access_control.rs | 96 +++++++-- .../src/access_control_tests.rs | 115 ++++++++++- contracts/access_control/src/errors.rs | 5 + contracts/access_control/src/lib.rs | 15 +- contracts/access_control/src/types.rs | 71 +------ contracts/common_types/src/lib.rs | 10 +- contracts/common_types/src/types.rs | 129 ++++++++++-- contracts/manage_hub/src/attendance_log.rs | 99 ++++++++++ contracts/manage_hub/src/errors.rs | 1 + contracts/manage_hub/src/lib.rs | 17 ++ contracts/manage_hub/src/membership_token.rs | 89 ++++++++- contracts/manage_hub/src/test.rs | 183 ++++++++++++++++++ contracts/manage_hub/src/types.rs | 11 +- 15 files changed, 731 insertions(+), 112 deletions(-) diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 33b0463c..02afa9a4 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "access_control" version = "1.0.0" dependencies = [ + "common_types", "soroban-sdk", ] diff --git a/contracts/access_control/Cargo.toml b/contracts/access_control/Cargo.toml index 28d4fe1a..e2683c7b 100644 --- a/contracts/access_control/Cargo.toml +++ b/contracts/access_control/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +common_types = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/access_control/src/access_control.rs b/contracts/access_control/src/access_control.rs index 0d769673..8fa5ff18 100644 --- a/contracts/access_control/src/access_control.rs +++ b/contracts/access_control/src/access_control.rs @@ -1,13 +1,14 @@ // Allow deprecated events API until migration to #[contractevent] macro #![allow(deprecated)] -use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, Symbol, Vec}; +use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, String, Symbol, Vec}; use crate::errors::{AccessControlError, AccessControlResult}; use crate::types::{ AccessControlConfig, MembershipInfo, MultiSigConfig, PendingAdminTransfer, PendingProposal, - ProposalAction, SubscriptionTierLevel, UserRole, UserSubscriptionStatus, + ProposalAction, UserSubscriptionStatus, }; +use common_types::{BatchOperationStatus, BatchSetRoleResult, SetRoleRequest, TierLevel, UserRole}; /// Storage keys for the access control module #[contracttype] @@ -140,6 +141,71 @@ impl AccessControlModule { Ok(()) } + pub fn batch_set_roles( + env: &Env, + caller: Address, + roles: Vec, + ) -> AccessControlResult> { + // Enforce batch size limit + if roles.len() > 25 { + return Err(AccessControlError::InvalidBatchSize); + } + + Self::require_initialized(env)?; + Self::require_not_paused(env)?; + Self::require_admin(env, &caller)?; + + let mut results: Vec = Vec::new(env); + + for req in roles.iter() { + if Self::is_blacklisted(env, &req.user) { + results.push_back(BatchSetRoleResult { + user: req.user.clone(), + status: BatchOperationStatus::Failed, + error: String::from_str(env, "User is blacklisted"), + }); + continue; + } + + match Self::validate_role_assignment(env, &req.user, &req.role) { + Ok(_) => { + let old_role = Self::get_role(env, req.user.clone()); + env.storage() + .persistent() + .set(&DataKey::UserRole(req.user.clone()), &req.role); + + env.events().publish( + ( + symbol_short!("role_set"), + req.user.clone(), + req.role.clone(), + ), + (caller.clone(), old_role), + ); + + results.push_back(BatchSetRoleResult { + user: req.user, + status: BatchOperationStatus::Success, + error: String::from_str(env, ""), + }); + } + Err(_) => { + results.push_back(BatchSetRoleResult { + user: req.user, + status: BatchOperationStatus::Failed, + error: String::from_str(env, "Validation failed"), + }); + } + } + } + + // Emit batch summary event + env.events() + .publish((symbol_short!("batch_rol"), roles.len()), results.len()); + + Ok(results) + } + /// Get role for a user pub fn get_role(env: &Env, user: Address) -> UserRole { env.storage() @@ -761,13 +827,11 @@ impl AccessControlModule { // Tier-Based Access Control Functions // ============================================================================ - /// Sets the subscription tier level for a user. Admin only. - /// This is used for caching tier info to avoid cross-contract calls. pub fn set_user_tier( env: &Env, caller: Address, user: Address, - tier_level: SubscriptionTierLevel, + tier_level: TierLevel, ) -> AccessControlResult<()> { Self::require_admin(env, &caller)?; @@ -785,11 +849,11 @@ impl AccessControlModule { } /// Gets the subscription tier level for a user. - pub fn get_user_tier(env: &Env, user: Address) -> SubscriptionTierLevel { + pub fn get_user_tier(env: &Env, user: Address) -> TierLevel { env.storage() .persistent() .get(&DataKey::UserTierLevel(user)) - .unwrap_or(SubscriptionTierLevel::Free) + .unwrap_or(TierLevel::Free) } /// Sets the required tier for a specific role. Admin only. @@ -797,7 +861,7 @@ impl AccessControlModule { env: &Env, caller: Address, role: UserRole, - required_tier: SubscriptionTierLevel, + required_tier: TierLevel, ) -> AccessControlResult<()> { Self::require_admin(env, &caller)?; @@ -818,18 +882,18 @@ impl AccessControlModule { } /// Gets the required tier for a specific role. - pub fn get_required_tier_for_role(env: &Env, role: UserRole) -> SubscriptionTierLevel { + pub fn get_required_tier_for_role(env: &Env, role: UserRole) -> TierLevel { env.storage() .persistent() .get(&DataKey::RequiredTierForRole(role)) - .unwrap_or(SubscriptionTierLevel::Free) + .unwrap_or(TierLevel::Free) } /// Checks if a user has the required tier level. pub fn check_tier_access( env: &Env, user: Address, - required_tier: SubscriptionTierLevel, + required_tier: TierLevel, ) -> AccessControlResult { Self::require_initialized(env)?; Self::require_not_paused(env)?; @@ -839,7 +903,7 @@ impl AccessControlModule { } let user_tier = Self::get_user_tier(env, user.clone()); - let has_access = user_tier.has_tier_access(&required_tier); + let has_access = user_tier >= required_tier; env.events().publish( ( @@ -857,7 +921,7 @@ impl AccessControlModule { pub fn require_tier_access( env: &Env, user: Address, - required_tier: SubscriptionTierLevel, + required_tier: TierLevel, ) -> AccessControlResult<()> { if !Self::check_tier_access(env, user, required_tier)? { return Err(AccessControlError::InsufficientRole); @@ -871,7 +935,7 @@ impl AccessControlModule { env: &Env, user: Address, required_role: UserRole, - required_tier: SubscriptionTierLevel, + required_tier: TierLevel, ) -> AccessControlResult { let has_role_access = Self::check_access(env, user.clone(), required_role)?; if !has_role_access { @@ -887,7 +951,7 @@ impl AccessControlModule { env: &Env, user: Address, required_role: UserRole, - required_tier: SubscriptionTierLevel, + required_tier: TierLevel, ) -> AccessControlResult<()> { if !Self::check_role_and_tier_access(env, user, required_role, required_tier)? { return Err(AccessControlError::InsufficientRole); @@ -910,7 +974,7 @@ impl AccessControlModule { let required_tier = Self::get_required_tier_for_role(env, role); let user_tier = Self::get_user_tier(env, user); - Ok(user_tier.has_tier_access(&required_tier)) + Ok(user_tier >= required_tier) } /// Gets the full subscription status for a user. diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 657a13cc..2f932741 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -1,6 +1,6 @@ use crate::access_control::AccessControlModule; use crate::errors::AccessControlError; -use crate::types::{AccessControlConfig, ProposalAction, UserRole}; +use crate::{AccessControlConfig, BatchOperationStatus, ProposalAction, SetRoleRequest, UserRole}; use soroban_sdk::{ testutils::{Address as _, Events}, Address, Env, Vec, @@ -756,3 +756,116 @@ fn test_proposal_events_emitted() { // Verify role was set after approval assert_eq!(client.get_role(&user), UserRole::Member); } + +#[test] +fn test_batch_set_roles_success() { + let (env, contract_id, admin, _, _) = setup_initialized_env(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + + let mut requests: Vec = Vec::new(&env); + + requests.push_back(SetRoleRequest { + user: user1.clone(), + role: UserRole::Member, + }); + requests.push_back(SetRoleRequest { + user: user2.clone(), + role: UserRole::Staff, + }); + requests.push_back(SetRoleRequest { + user: user3.clone(), + role: UserRole::Visitor, + }); + + let results = env.as_contract(&contract_id, || { + AccessControlModule::batch_set_roles(&env, admin.clone(), requests).unwrap() + }); + + assert_eq!(results.len(), 3); + + for result in results.iter() { + assert_eq!(result.status, BatchOperationStatus::Success); + } + + // Verify roles were set + env.as_contract(&contract_id, || { + assert_eq!(AccessControlModule::get_role(&env, user1), UserRole::Member); + assert_eq!(AccessControlModule::get_role(&env, user2), UserRole::Staff); + assert_eq!( + AccessControlModule::get_role(&env, user3), + UserRole::Visitor + ); + }); +} + +#[test] +fn test_batch_set_roles_partial_failure() { + let (env, contract_id, admin, user1, user2) = setup_initialized_env(); + + env.as_contract(&contract_id, || { + // Blacklist user2 + AccessControlModule::blacklist_user(&env, admin.clone(), user2.clone()).unwrap(); + + // Ensure user1 has no role initially + assert_eq!( + AccessControlModule::get_role(&env, user1.clone()), + UserRole::Guest + ); + + let mut requests: Vec = Vec::new(&env); + + // Valid request for user1 + requests.push_back(SetRoleRequest { + user: user1.clone(), + role: UserRole::Member, + }); + + // Invalid request for blacklisted user2 + requests.push_back(SetRoleRequest { + user: user2.clone(), + role: UserRole::Member, + }); + + let results = AccessControlModule::batch_set_roles(&env, admin.clone(), requests).unwrap(); + + assert_eq!(results.len(), 2); + + let res1 = results.get(0).unwrap(); + let res2 = results.get(1).unwrap(); + + assert_eq!(res1.user, user1); + assert_eq!(res1.status, BatchOperationStatus::Success); + + assert_eq!(res2.user, user2); + assert_eq!(res2.status, BatchOperationStatus::Failed); + assert!(res2.error.len() > 0); + + // Verify user1 role set, user2 role unchanged + assert_eq!(AccessControlModule::get_role(&env, user1), UserRole::Member); + assert_eq!(AccessControlModule::get_role(&env, user2), UserRole::Guest); + }); +} + +#[test] +fn test_batch_set_roles_limit_exceeded() { + let (env, contract_id, admin, _, _) = setup_initialized_env(); + + env.as_contract(&contract_id, || { + let mut requests: Vec = Vec::new(&env); + let user = Address::generate(&env); + + // Create 26 requests (limit is 25) + for _ in 0..26 { + requests.push_back(SetRoleRequest { + user: user.clone(), + role: UserRole::Member, + }); + } + + let result = AccessControlModule::batch_set_roles(&env, admin.clone(), requests); + assert_eq!(result.unwrap_err(), AccessControlError::InvalidBatchSize); + }); +} diff --git a/contracts/access_control/src/errors.rs b/contracts/access_control/src/errors.rs index b9ea76eb..04d60935 100644 --- a/contracts/access_control/src/errors.rs +++ b/contracts/access_control/src/errors.rs @@ -37,6 +37,8 @@ pub enum AccessControlError { MaxRolesExceeded = 114, /// Contract is paused ContractPaused = 115, + /// Invalid batch size + InvalidBatchSize = 116, } impl AccessControlError { @@ -62,7 +64,9 @@ impl AccessControlError { AccessControlError::InvalidAddress => "Invalid address provided", AccessControlError::RoleHierarchyViolation => "Role hierarchy violation", AccessControlError::MaxRolesExceeded => "Maximum roles per user exceeded", + AccessControlError::ContractPaused => "Contract is currently paused", + AccessControlError::InvalidBatchSize => "Invalid batch size (max 25)", } } @@ -132,5 +136,6 @@ mod tests { assert_eq!(AccessControlError::Unauthorized as u32, 100); assert_eq!(AccessControlError::AdminRequired as u32, 101); assert_eq!(AccessControlError::ContractPaused as u32, 115); + assert_eq!(AccessControlError::InvalidBatchSize as u32, 116); } } diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index ca7bd0b6..0d93454d 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -10,8 +10,13 @@ pub mod types; mod access_control_tests; pub use access_control::AccessControlModule; +pub use common_types::{ + BatchOperationStatus, BatchSetRoleResult, SetRoleRequest, TierLevel, UserRole, +}; pub use errors::{AccessControlError, AccessControlResult}; -pub use types::{AccessControlConfig, MembershipInfo, MultiSigConfig, ProposalAction, UserRole}; +pub use types::{ + AccessControlConfig, MembershipInfo, MultiSigConfig, ProposalAction, UserSubscriptionStatus, +}; #[contract] pub struct AccessControl; @@ -26,6 +31,14 @@ impl AccessControl { AccessControlModule::set_role(&env, admin, user, role).unwrap() } + pub fn batch_set_roles( + env: Env, + admin: Address, + roles: Vec, + ) -> Vec { + AccessControlModule::batch_set_roles(&env, admin, roles).unwrap() + } + pub fn get_role(env: Env, user: Address) -> UserRole { AccessControlModule::get_role(&env, user) } diff --git a/contracts/access_control/src/types.rs b/contracts/access_control/src/types.rs index 34adedd7..28e716d1 100644 --- a/contracts/access_control/src/types.rs +++ b/contracts/access_control/src/types.rs @@ -1,41 +1,6 @@ +use common_types::{TierLevel, UserRole}; use soroban_sdk::{contracttype, Address, Vec}; -/// User roles in the access control system -/// Implements a hierarchical role system where Admin > Member > Guest -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -pub enum UserRole { - Guest = 0, - Member = 1, - Admin = 2, -} - -impl UserRole { - /// Check if this role has sufficient privileges for the required role - /// Returns true if this role >= required_role in the hierarchy - pub fn has_access(&self, required_role: &UserRole) -> bool { - self >= required_role - } - - /// Convert role to string representation - pub fn as_str(&self) -> &'static str { - match self { - UserRole::Guest => "Guest", - UserRole::Member => "Member", - UserRole::Admin => "Admin", - } - } - - pub fn parse_from_str(role_str: &str) -> Option { - match role_str.to_ascii_lowercase().as_str() { - "guest" => Some(UserRole::Guest), - "member" => Some(UserRole::Member), - "admin" => Some(UserRole::Admin), - _ => None, - } - } -} - /// Membership token information for cross-contract integration #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -64,44 +29,12 @@ pub struct AccessControlConfig { pub enforce_tier_restrictions: bool, } -/// Subscription tier level for access control integration. -/// Must match TierLevel in common_types. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -pub enum SubscriptionTierLevel { - /// Free tier with limited features - Free = 0, - /// Basic paid tier - Basic = 1, - /// Professional tier - Pro = 2, - /// Enterprise tier with all features - Enterprise = 3, -} - -impl SubscriptionTierLevel { - /// Check if this tier has sufficient privileges for the required tier - pub fn has_tier_access(&self, required_tier: &SubscriptionTierLevel) -> bool { - self >= required_tier - } - - /// Convert tier level to string representation - pub fn as_str(&self) -> &'static str { - match self { - SubscriptionTierLevel::Free => "Free", - SubscriptionTierLevel::Basic => "Basic", - SubscriptionTierLevel::Pro => "Pro", - SubscriptionTierLevel::Enterprise => "Enterprise", - } - } -} - /// User subscription info for access control validation #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct UserSubscriptionStatus { /// User's current subscription tier level - pub tier_level: SubscriptionTierLevel, + pub tier_level: TierLevel, /// Whether subscription is currently active pub is_active: bool, /// Subscription expiry timestamp diff --git a/contracts/common_types/src/lib.rs b/contracts/common_types/src/lib.rs index f69a18d7..256245b9 100644 --- a/contracts/common_types/src/lib.rs +++ b/contracts/common_types/src/lib.rs @@ -9,10 +9,12 @@ mod types; // Re-export all types pub use types::{ - validate_attribute, validate_metadata, AttendanceAction, MembershipStatus, MetadataUpdate, - MetadataValue, SubscriptionPlan, SubscriptionTier, TierChangeRequest, TierChangeStatus, - TierChangeType, TierFeature, TierLevel, TierPromotion, TokenMetadata, UserRole, - MAX_ATTRIBUTES_COUNT, MAX_ATTRIBUTE_KEY_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_TEXT_VALUE_LENGTH, + validate_attribute, validate_metadata, AttendanceAction, AttendanceLogRequest, + BatchAttendanceResult, BatchIssueTokenResult, BatchOperationStatus, BatchSetRoleResult, + IssueTokenRequest, MembershipStatus, MetadataUpdate, MetadataValue, SetRoleRequest, + SubscriptionPlan, SubscriptionTier, TierChangeRequest, TierChangeStatus, TierChangeType, + TierFeature, TierLevel, TierPromotion, TokenMetadata, UserRole, MAX_ATTRIBUTES_COUNT, + MAX_ATTRIBUTE_KEY_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_TEXT_VALUE_LENGTH, }; #[cfg(test)] diff --git a/contracts/common_types/src/types.rs b/contracts/common_types/src/types.rs index 56d9a60b..026ceee5 100644 --- a/contracts/common_types/src/types.rs +++ b/contracts/common_types/src/types.rs @@ -148,16 +148,45 @@ pub enum AttendanceAction { /// * `Admin` - Administrator with full access /// * `Visitor` - Temporary visitor with limited access #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] pub enum UserRole { + /// Temporary visitor with limited access + Visitor = 0, + /// Guest user (equivalent to visitor or unauthenticated) + Guest = 1, /// Regular member - Member, + Member = 2, /// Staff member with elevated privileges - Staff, + Staff = 3, /// Administrator with full access - Admin, - /// Temporary visitor with limited access - Visitor, + Admin = 4, +} + +impl UserRole { + pub fn has_access(&self, required_role: &UserRole) -> bool { + self >= required_role + } + + pub fn as_str(&self) -> &'static str { + match self { + UserRole::Visitor => "Visitor", + UserRole::Guest => "Guest", + UserRole::Member => "Member", + UserRole::Staff => "Staff", + UserRole::Admin => "Admin", + } + } + + pub fn parse_from_str(role_str: &str) -> Option { + match role_str.to_ascii_lowercase().as_str() { + "visitor" => Some(UserRole::Visitor), + "guest" => Some(UserRole::Guest), + "member" => Some(UserRole::Member), + "staff" => Some(UserRole::Staff), + "admin" => Some(UserRole::Admin), + _ => None, + } + } } /// Membership status types. @@ -198,16 +227,22 @@ pub enum MembershipStatus { /// * `Pro` - Professional tier with advanced features /// * `Enterprise` - Full-featured enterprise tier #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] pub enum TierLevel { /// Free tier with limited features - Free, + Free = 0, /// Basic paid tier - Basic, + Basic = 1, /// Professional tier - Pro, + Pro = 2, /// Enterprise tier with all features - Enterprise, + Enterprise = 3, +} + +impl TierLevel { + pub fn has_tier_access(&self, required_tier: &TierLevel) -> bool { + self >= required_tier + } } /// Feature flags for subscription tiers. @@ -396,6 +431,78 @@ pub enum TierChangeStatus { Rejected, } +// ============================================================================ +// Batch Operation Types +// ============================================================================ + +/// Status of an individual batch operation. +/// +/// # Variants +/// * `Success` - The operation succeeded +/// * `Failed` - The operation failed +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BatchOperationStatus { + /// Operation succeeded + Success, + /// Operation failed + Failed, +} + +/// Request structure for batch token issuance. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IssueTokenRequest { + pub id: soroban_sdk::BytesN<32>, + pub user: Address, + pub expiry_date: u64, +} + +/// Result of a single token issuance in a batch. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchIssueTokenResult { + pub id: soroban_sdk::BytesN<32>, + pub status: BatchOperationStatus, + pub error: String, +} + +/// Request structure for batch attendance logging. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttendanceLogRequest { + pub id: soroban_sdk::BytesN<32>, + pub user_id: Address, + pub action: AttendanceAction, + pub details: Map, +} + +/// Result of a single attendance log in a batch. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchAttendanceResult { + pub id: soroban_sdk::BytesN<32>, + pub status: BatchOperationStatus, + pub error: String, +} + +/// Request structure for batch role setting. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SetRoleRequest { + pub user: Address, + pub role: UserRole, +} + +/// Result of a single role assignment in a batch. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchSetRoleResult { + pub user: Address, + pub status: BatchOperationStatus, + pub error: String, +} + // ============================================================================ // Metadata Validation Functions // ============================================================================ diff --git a/contracts/manage_hub/src/attendance_log.rs b/contracts/manage_hub/src/attendance_log.rs index cfa7f73b..944e5868 100644 --- a/contracts/manage_hub/src/attendance_log.rs +++ b/contracts/manage_hub/src/attendance_log.rs @@ -3,6 +3,7 @@ use crate::errors::Error; use crate::types::AttendanceAction; +use common_types::{AttendanceLogRequest, BatchAttendanceResult, BatchOperationStatus}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Vec}; #[contracttype] @@ -25,6 +26,104 @@ pub struct AttendanceLog { pub struct AttendanceLogModule; impl AttendanceLogModule { + pub fn batch_log_attendance( + env: Env, + logs: Vec, + ) -> Result, Error> { + // Enforce batch size limit + if logs.len() > 100 { + return Err(Error::InvalidBatchSize); + } + + let mut results: Vec = Vec::new(&env); + + for log_req in logs.iter() { + // Enforce initiator authentication for each log + // Note: In a real batch scenario, the caller might be an admin logging for others, + // or the user themselves. If it's the user, they must sign. + // For this implementation, we assume the caller must be authorized for the user_id + // specified in the request, or we could check if the caller is an admin. + // However, `log_attendance` enforces `user_id.require_auth()`. + // We will stick to `user_id.require_auth()` for now as per the single log implementation. + // If the user submits a batch for themselves, this works. + // If an admin submits for multiple users, this would fail unless we have admin override logic. + // Given the requirements don't specify admin override for attendance, we strictly follow + // the existing pattern or we can relax it if needed. + // "Implement batch ... logging ... or managing multiple user roles" + // Usually batch attendance is for a kiosk or admin. + // But let's look at `log_attendance`: `user_id.require_auth()`. + // If we want to allow batch logging by an admin, we might need to check admin status. + // But `log_attendance` doesn't check admin. + // Let's rely on `require_auth` for now. If it fails, we mark as failed. + + // Actually, we can't easily "try" require_auth without panicking in the current Soroban version + // unless we are careful. But `require_auth` panics on failure. + // So we can assume the batch is submitted by someone who has auth over these keys (e.g. multi-sig or single user). + // OR we assume this is an admin function? + // "Batch Attendance Logging - Implement batch attendance operations... optimized storage patterns" + // Let's implement it such that it tries to log. If `require_auth` fails, the whole tx fails (standard Soroban). + // Partial success is only for logic errors we can catch. + // But wait, `log_attendance` takes `user_id`. + // If I am a teacher logging for students, I need admin rights. + // The current `log_attendance` enforces `user_id.require_auth()`, meaning ONLY the user can log their own attendance. + // This suggests the batch is for a user logging multiple times (weird) OR the requirements imply we should allow an admin to log for others. + // However, I should stick to the requested changes. + // Let's look at the requirements: "Batch attendance operations". + // I will implement it such that it calls `require_auth` on the user_id. + // If this is not desired, the underlying `log_attendance` would need changing too. + // Wait, if I do `require_auth` inside the loop, and one fails, the whole batch fails. + // To support partial success for AUHTORIZATION failures is hard in smart contracts without signature verification libraries. + // We will assume the batch transaction is signed by all necessary parties or the caller has the authority. + // But for this specific task, I'll assume we just loop and call internal logic. + + // ERROR: logic inside loop using `require_auth` will panic if not signed. + // If we want "Partial Success", we should probably catch errors that are NOT auth errors. + // e.g. details too long. + + // Let's look at `log_attendance_internal`. It does validation. + // We can call that. + // But we need to handle auth. + // If we blindly call `log_attendance_internal`, we bypass auth. + // So we MUST check auth. + // Implementation decision: enforce auth on `user_id`. + log_req.user_id.require_auth(); + + match Self::log_attendance_internal( + env.clone(), + log_req.id.clone(), + log_req.user_id.clone(), + log_req.action, + log_req.details, + ) { + Ok(_) => { + results.push_back(BatchAttendanceResult { + id: log_req.id, + status: BatchOperationStatus::Success, + error: String::from_str(&env, ""), + }); + } + Err(e) => { + // Map error to string (simplified for this context) + let err_msg = match e { + Error::InvalidEventDetails => "Invalid details", + _ => "Unknown error", + }; + results.push_back(BatchAttendanceResult { + id: log_req.id, + status: BatchOperationStatus::Failed, + error: String::from_str(&env, err_msg), + }); + } + } + } + + // Emit batch summary event + env.events() + .publish((symbol_short!("batch_att"), logs.len()), results.len()); + + Ok(results) + } + pub fn log_attendance( env: Env, id: BytesN<32>, diff --git a/contracts/manage_hub/src/errors.rs b/contracts/manage_hub/src/errors.rs index 04398e5f..c9cd5056 100644 --- a/contracts/manage_hub/src/errors.rs +++ b/contracts/manage_hub/src/errors.rs @@ -49,4 +49,5 @@ pub enum Error { TierAlreadyExists = 40, TierNotActive = 41, TierChangeNotFound = 42, + InvalidBatchSize = 43, } diff --git a/contracts/manage_hub/src/lib.rs b/contracts/manage_hub/src/lib.rs index 0036bbeb..6cb09435 100644 --- a/contracts/manage_hub/src/lib.rs +++ b/contracts/manage_hub/src/lib.rs @@ -8,6 +8,9 @@ mod subscription; mod types; use attendance_log::{AttendanceLog, AttendanceLogModule}; +use common_types::{ + AttendanceLogRequest, BatchAttendanceResult, BatchIssueTokenResult, IssueTokenRequest, +}; use common_types::{MetadataUpdate, MetadataValue, TokenMetadata}; use errors::Error; use membership_token::{MembershipToken, MembershipTokenContract}; @@ -37,6 +40,13 @@ impl Contract { Ok(()) } + pub fn batch_issue_tokens( + env: Env, + requests: Vec, + ) -> Result, Error> { + MembershipTokenContract::batch_issue_tokens(env, requests) + } + pub fn transfer_token(env: Env, id: BytesN<32>, new_user: Address) -> Result<(), Error> { MembershipTokenContract::transfer_token(env, id, new_user)?; Ok(()) @@ -61,6 +71,13 @@ impl Contract { AttendanceLogModule::log_attendance(env, id, user_id, action, details) } + pub fn batch_log_attendance( + env: Env, + logs: Vec, + ) -> Result, Error> { + AttendanceLogModule::batch_log_attendance(env, logs) + } + pub fn get_logs_for_user(env: Env, user_id: Address) -> Vec { AttendanceLogModule::get_logs_for_user(env, user_id) } diff --git a/contracts/manage_hub/src/membership_token.rs b/contracts/manage_hub/src/membership_token.rs index 5002efe8..39efed3e 100644 --- a/contracts/manage_hub/src/membership_token.rs +++ b/contracts/manage_hub/src/membership_token.rs @@ -4,7 +4,8 @@ use crate::errors::Error; use crate::types::MembershipStatus; use common_types::{ - validate_attribute, validate_metadata, MetadataUpdate, MetadataValue, TokenMetadata, + validate_attribute, validate_metadata, BatchIssueTokenResult, BatchOperationStatus, + IssueTokenRequest, MetadataUpdate, MetadataValue, TokenMetadata, }; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Vec}; @@ -80,6 +81,92 @@ impl MembershipTokenContract { Ok(()) } + pub fn batch_issue_tokens( + env: Env, + requests: Vec, + ) -> Result, Error> { + // Enforce batch size limit + if requests.len() > 50 { + return Err(Error::InvalidBatchSize); + } + + // Get admin from storage - if no admin is set, this will panic + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::AdminNotSet)?; + admin.require_auth(); + + let mut results: Vec = Vec::new(&env); + let current_time = env.ledger().timestamp(); + + for request in requests.iter() { + // Validate expiry date (must be in the future) + if request.expiry_date <= current_time { + results.push_back(BatchIssueTokenResult { + id: request.id.clone(), + status: BatchOperationStatus::Failed, + error: String::from_str(&env, "Invalid expiry date"), + }); + continue; + } + + // Check if token already exists + if env + .storage() + .persistent() + .has(&DataKey::Token(request.id.clone())) + { + results.push_back(BatchIssueTokenResult { + id: request.id.clone(), + status: BatchOperationStatus::Failed, + error: String::from_str(&env, "Token already exists"), + }); + continue; + } + + // Create and store token + let token = MembershipToken { + id: request.id.clone(), + user: request.user.clone(), + status: MembershipStatus::Active, + issue_date: current_time, + expiry_date: request.expiry_date, + }; + env.storage() + .persistent() + .set(&DataKey::Token(request.id.clone()), &token); + + // Emit token issued event + env.events().publish( + ( + symbol_short!("token_iss"), + request.id.clone(), + request.user.clone(), + ), + ( + admin.clone(), + current_time, + request.expiry_date, + MembershipStatus::Active, + ), + ); + + results.push_back(BatchIssueTokenResult { + id: request.id, + status: BatchOperationStatus::Success, + error: String::from_str(&env, ""), + }); + } + + // Emit batch summary event + env.events() + .publish((symbol_short!("batch_iss"), requests.len()), results.len()); + + Ok(results) + } + pub fn transfer_token(env: Env, id: BytesN<32>, new_user: Address) -> Result<(), Error> { // Retrieve token let mut token: MembershipToken = env diff --git a/contracts/manage_hub/src/test.rs b/contracts/manage_hub/src/test.rs index 369105dc..8bfa53a1 100644 --- a/contracts/manage_hub/src/test.rs +++ b/contracts/manage_hub/src/test.rs @@ -1274,3 +1274,186 @@ fn test_renew_paused_subscription() { // Try to renew paused subscription - should fail client.renew_subscription(&subscription_id, &payment_token, &amount, &duration); } + +// ==================== Batch Operation Tests ==================== + +#[test] +fn test_batch_issue_tokens_success() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + + let id1 = BytesN::<32>::random(&env); + let id2 = BytesN::<32>::random(&env); + let id3 = BytesN::<32>::random(&env); + + let expiry = env.ledger().timestamp() + 86400; + + let mut requests: Vec = Vec::new(&env); + + requests.push_back(common_types::IssueTokenRequest { + id: id1.clone(), + user: user1.clone(), + expiry_date: expiry, + }); + requests.push_back(common_types::IssueTokenRequest { + id: id2.clone(), + user: user2.clone(), + expiry_date: expiry, + }); + requests.push_back(common_types::IssueTokenRequest { + id: id3.clone(), + user: user3.clone(), + expiry_date: expiry, + }); + + let results = client.batch_issue_tokens(&requests); + + assert_eq!(results.len(), 3); + + for result in results.iter() { + assert_eq!(result.status, common_types::BatchOperationStatus::Success); + } +} + +#[test] +fn test_batch_issue_tokens_partial_failure() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let id1 = BytesN::<32>::random(&env); + let id2 = BytesN::<32>::random(&env); + + env.ledger().with_mut(|l| l.timestamp = 10000); + let valid_expiry = env.ledger().timestamp() + 86400; + let invalid_expiry = env.ledger().timestamp() - 100; // Past + + let mut requests: Vec = Vec::new(&env); + + // Valid request + requests.push_back(common_types::IssueTokenRequest { + id: id1.clone(), + user: user1.clone(), + expiry_date: valid_expiry, + }); + + // Invalid request (expired) + requests.push_back(common_types::IssueTokenRequest { + id: id2.clone(), + user: user2.clone(), + expiry_date: invalid_expiry, + }); + + let results = client.batch_issue_tokens(&requests); + + assert_eq!(results.len(), 2); + + let res1 = results.get(0).unwrap(); + let res2 = results.get(1).unwrap(); + + assert_eq!(res1.id, id1); + assert_eq!(res1.status, common_types::BatchOperationStatus::Success); + + assert_eq!(res2.id, id2); + assert_eq!(res2.status, common_types::BatchOperationStatus::Failed); +} + +#[test] +fn test_batch_log_attendance_success() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let id1 = BytesN::<32>::random(&env); + let id2 = BytesN::<32>::random(&env); + + let details = map![ + &env, + ( + String::from_str(&env, "location"), + String::from_str(&env, "office") + ) + ]; + + let mut logs: Vec = Vec::new(&env); + + logs.push_back(common_types::AttendanceLogRequest { + id: id1.clone(), + user_id: user1.clone(), + action: common_types::AttendanceAction::ClockIn, + details: details.clone(), + }); + + logs.push_back(common_types::AttendanceLogRequest { + id: id2.clone(), + user_id: user2.clone(), + action: common_types::AttendanceAction::ClockOut, + details: details.clone(), + }); + + let results = client.batch_log_attendance(&logs); + + assert_eq!(results.len(), 2); + + for result in results.iter() { + assert_eq!(result.status, common_types::BatchOperationStatus::Success); + } + + // Verify logs exist + let user1_logs = client.get_logs_for_user(&user1); + assert_eq!(user1_logs.len(), 1); + + let user2_logs = client.get_logs_for_user(&user2); + assert_eq!(user2_logs.len(), 1); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #43)")] // InvalidBatchSize +fn test_batch_issue_tokens_limit_exceeded() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + + let mut requests: Vec = Vec::new(&env); + let user = Address::generate(&env); + let expiry = env.ledger().timestamp() + 86400; + + // Create 51 requests (limit is 50) + for _ in 0..51 { + requests.push_back(common_types::IssueTokenRequest { + id: BytesN::<32>::random(&env), + user: user.clone(), + expiry_date: expiry, + }); + } + + client.batch_issue_tokens(&requests); +} diff --git a/contracts/manage_hub/src/types.rs b/contracts/manage_hub/src/types.rs index 25d591bb..826f60be 100644 --- a/contracts/manage_hub/src/types.rs +++ b/contracts/manage_hub/src/types.rs @@ -3,17 +3,10 @@ use soroban_sdk::{contracttype, Address, String, Vec}; // Re-export types from common_types for consistency pub use common_types::MembershipStatus; pub use common_types::{ - SubscriptionTier, TierChangeRequest, TierChangeStatus, TierChangeType, TierFeature, TierLevel, - TierPromotion, + AttendanceAction, SubscriptionTier, TierChangeRequest, TierChangeStatus, TierChangeType, + TierFeature, TierLevel, TierPromotion, }; -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum AttendanceAction { - ClockIn, - ClockOut, -} - /// Billing cycle for subscriptions. #[contracttype] #[derive(Clone, Debug, PartialEq)]