diff --git a/src/base/errors.cairo b/src/base/errors.cairo index b052204..f288189 100644 --- a/src/base/errors.cairo +++ b/src/base/errors.cairo @@ -77,6 +77,8 @@ pub mod RegistrationErrors { // pub const IMMUTABLE_TIMESTAMP: felt252 = 'Registration timestamp cannot be changed'; pub const USER_NOT_SUSPENDED: felt252 = 'User is not suspended'; pub const RECIPIENT_NOT_FOUND: felt252 = 'Recipient not found'; + + pub const NOT_USER_ADMIN: felt252 = 'Not user admin'; } pub mod KYCErrors { diff --git a/src/base/types.cairo b/src/base/types.cairo index e220fab..2f25179 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -2,7 +2,7 @@ use starknet::ContractAddress; /// User profile structure containing user information -#[derive(Copy, Drop, Serde, starknet::Store)] +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq, Debug)] pub struct UserProfile { /// User's contract address pub address: ContractAddress, diff --git a/src/component/user_management.cairo b/src/component/user_management.cairo deleted file mode 100644 index 4fee7d5..0000000 --- a/src/component/user_management.cairo +++ /dev/null @@ -1,222 +0,0 @@ -use starknet::ContractAddress; -use starkremit_contract::base::types::{RegistrationRequest, RegistrationStatus, UserProfile}; -#[starknet::interface] -pub trait IUserManagement { - fn register_user(ref self: TContractState, registration_data: RegistrationRequest) -> bool; - fn get_user_profile(self: @TContractState, user_address: ContractAddress) -> UserProfile; - fn update_user_profile(ref self: TContractState, updated_profile: UserProfile) -> bool; - fn is_user_registered(self: @TContractState, user_address: ContractAddress) -> bool; - fn get_registration_status( - self: @TContractState, user_address: ContractAddress, - ) -> RegistrationStatus; - fn deactivate_user(ref self: TContractState, user_address: ContractAddress) -> bool; - fn reactivate_user(ref self: TContractState, user_address: ContractAddress) -> bool; - fn get_total_users(self: @TContractState) -> u256; -} -#[starknet::component] -pub mod user_management_component { - use core::num::traits::Zero; - use starknet::storage::{ - Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, - StoragePointerWriteAccess, - }; - use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; - use starkremit_contract::base::errors::RegistrationErrors; - use starkremit_contract::base::types::{ - KYCLevel, RegistrationRequest, RegistrationStatus, UserProfile, - }; - use super::*; - - #[storage] - pub struct Storage { - user_profiles: Map, - email_registry: Map, - phone_registry: Map, - registration_status: Map, - total_users: u256, - registration_enabled: bool, - } - - #[event] - #[derive(Drop, starknet::Event)] - pub enum Event { - UserRegistered: UserRegistered, - UserProfileUpdated: UserProfileUpdated, - UserDeactivated: UserDeactivated, - UserReactivated: UserReactivated, - } - - #[derive(Drop, starknet::Event)] - pub struct UserRegistered { - user_address: ContractAddress, - email_hash: felt252, - registration_timestamp: u64, - } - #[derive(Drop, starknet::Event)] - pub struct UserProfileUpdated { - user_address: ContractAddress, - updated_fields: felt252, - } - #[derive(Drop, starknet::Event)] - pub struct UserDeactivated { - user_address: ContractAddress, - admin: ContractAddress, - } - #[derive(Drop, starknet::Event)] - pub struct UserReactivated { - user_address: ContractAddress, - admin: ContractAddress, - } - - - #[embeddable_as(UserManagement)] - pub impl UserManagementImpl< - TContractState, +HasComponent, - > of IUserManagement> { - fn register_user( - ref self: ComponentState, registration_data: RegistrationRequest, - ) -> bool { - let caller = get_caller_address(); - assert(!caller.is_zero(), RegistrationErrors::ZERO_ADDRESS); - // Check for duplicate email - let existing_email_user = self.email_registry.read(registration_data.email_hash); - assert(existing_email_user.is_zero(), RegistrationErrors::EMAIL_ALREADY_EXISTS); - // Check for duplicate phone - let existing_phone_user = self.phone_registry.read(registration_data.phone_hash); - assert(existing_phone_user.is_zero(), RegistrationErrors::PHONE_ALREADY_EXISTS); - // Set registration status to in progress - self.registration_status.write(caller, RegistrationStatus::InProgress); - // Create user profile - let current_timestamp = get_block_timestamp(); - let user_profile = UserProfile { - address: caller, - user_address: caller, - email_hash: registration_data.email_hash, - phone_hash: registration_data.phone_hash, - full_name: registration_data.full_name, - kyc_level: KYCLevel::None, - registration_timestamp: current_timestamp, - is_active: true, - country_code: registration_data.country_code, - }; - self.user_profiles.write(caller, user_profile); - self.email_registry.write(registration_data.email_hash, caller); - self.phone_registry.write(registration_data.phone_hash, caller); - self.registration_status.write(caller, RegistrationStatus::Completed); - let current_total = self.total_users.read(); - self.total_users.write(current_total + 1); - self - .emit( - Event::UserRegistered( - UserRegistered { - user_address: caller, - email_hash: registration_data.email_hash, - registration_timestamp: current_timestamp, - }, - ), - ); - true - } - fn get_user_profile( - self: @ComponentState, user_address: ContractAddress, - ) -> UserProfile { - let status = self.registration_status.read(user_address); - match status { - RegistrationStatus::Completed => {}, - _ => { assert(false, RegistrationErrors::USER_NOT_FOUND); }, - } - self.user_profiles.read(user_address) - } - fn update_user_profile( - ref self: ComponentState, updated_profile: UserProfile, - ) -> bool { - let caller = get_caller_address(); - assert(updated_profile.user_address == caller, 'Cannot update other profile'); - let status = self.registration_status.read(caller); - match status { - RegistrationStatus::Completed => {}, - _ => { assert(false, RegistrationErrors::USER_NOT_FOUND); }, - } - let current_profile = self.user_profiles.read(caller); - assert(current_profile.is_active, RegistrationErrors::USER_INACTIVE); - assert(updated_profile.address == current_profile.address, 'Cannot change address'); - assert( - updated_profile.registration_timestamp == current_profile.registration_timestamp, - 'Cannot change timestamp', - ); - if updated_profile.email_hash != current_profile.email_hash { - let zero_address: ContractAddress = 0.try_into().unwrap(); - let existing_email_user = self.email_registry.read(updated_profile.email_hash); - assert(existing_email_user.is_zero(), RegistrationErrors::EMAIL_ALREADY_EXISTS); - self.email_registry.write(current_profile.email_hash, zero_address); - self.email_registry.write(updated_profile.email_hash, caller); - } - if updated_profile.phone_hash != current_profile.phone_hash { - let zero_address: ContractAddress = 0.try_into().unwrap(); - let existing_phone_user = self.phone_registry.read(updated_profile.phone_hash); - assert(existing_phone_user.is_zero(), RegistrationErrors::PHONE_ALREADY_EXISTS); - self.phone_registry.write(current_profile.phone_hash, zero_address); - self.phone_registry.write(updated_profile.phone_hash, caller); - } - self.user_profiles.write(caller, updated_profile); - self - .emit( - Event::UserProfileUpdated( - UserProfileUpdated { - user_address: caller, updated_fields: 'profile_updated', - }, - ), - ); - true - } - fn is_user_registered( - self: @ComponentState, user_address: ContractAddress, - ) -> bool { - let status = self.registration_status.read(user_address); - match status { - RegistrationStatus::Completed => true, - _ => false, - } - } - fn get_registration_status( - self: @ComponentState, user_address: ContractAddress, - ) -> RegistrationStatus { - self.registration_status.read(user_address) - } - fn deactivate_user( - ref self: ComponentState, user_address: ContractAddress, - ) -> bool { - let caller = get_caller_address(); - let is_registered = match self.registration_status.read(user_address) { - RegistrationStatus::Completed => true, - _ => false, - }; - assert(is_registered, RegistrationErrors::USER_NOT_FOUND); - let mut user_profile = self.user_profiles.read(user_address); - user_profile.is_active = false; - self.user_profiles.write(user_address, user_profile); - self.registration_status.write(user_address, RegistrationStatus::Suspended); - self.emit(Event::UserDeactivated(UserDeactivated { user_address, admin: caller })); - true - } - fn reactivate_user( - ref self: ComponentState, user_address: ContractAddress, - ) -> bool { - let caller = get_caller_address(); - let status = self.registration_status.read(user_address); - match status { - RegistrationStatus::Suspended => {}, - _ => { assert(false, 'User not suspended'); }, - } - let mut user_profile = self.user_profiles.read(user_address); - user_profile.is_active = true; - self.user_profiles.write(user_address, user_profile); - self.registration_status.write(user_address, RegistrationStatus::Completed); - self.emit(Event::UserReactivated(UserReactivated { user_address, admin: caller })); - true - } - fn get_total_users(self: @ComponentState) -> u256 { - self.total_users.read() - } - } -} diff --git a/src/component/user_management/mock.cairo b/src/component/user_management/mock.cairo new file mode 100644 index 0000000..0f69c4a --- /dev/null +++ b/src/component/user_management/mock.cairo @@ -0,0 +1,49 @@ +use openzeppelin::access::ownable::OwnableComponent; +use starknet::ContractAddress; + +#[starknet::contract] +pub mod MockUserManagementContract { + use starkremit_contract::component::user_management::user_management::user_management_component; + use super::*; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!( + path: user_management_component, + storage: user_management_component, + event: UserManagementEvent, + ); + + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + #[abi(embed_v0)] + impl UserManagementImpl = + user_management_component::UserManagement; + impl UserManagementInternalImpl = user_management_component::InternalImpl; + + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + UserManagementEvent: user_management_component::Event, + } + + #[storage] + #[allow(starknet::colliding_storage_paths)] + struct Storage { + #[substorage(v0)] + user_management_component: user_management_component::Storage, + #[substorage(v0)] + ownable: OwnableComponent::Storage, + } + + #[constructor] + fn constructor(ref self: ContractState, owner: ContractAddress) { + self.ownable.initializer(owner); + self.user_management_component.initializer(); + } +} diff --git a/src/component/user_management/test.cairo b/src/component/user_management/test.cairo new file mode 100644 index 0000000..39d56c8 --- /dev/null +++ b/src/component/user_management/test.cairo @@ -0,0 +1,508 @@ +#[cfg(test)] +mod user_management_tests { + use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, spy_events, + start_cheat_block_timestamp_global, start_cheat_caller_address, stop_cheat_caller_address, + }; + use starknet::{ContractAddress, contract_address_const, get_block_timestamp}; + use starkremit_contract::base::types::{ + KYCLevel, RegistrationRequest, RegistrationStatus, UserProfile, + }; + use starkremit_contract::component::user_management::user_management::{ + IUserManagementDispatcher, IUserManagementDispatcherTrait, user_management_component, + }; + + pub fn OWNER() -> ContractAddress { + contract_address_const::<'OWNER'>() + } + + pub fn ADMIN() -> ContractAddress { + contract_address_const::<'ADMIN'>() + } + + pub fn ADMIN2() -> ContractAddress { + contract_address_const::<'ADMIN'>() + } + + pub fn USER() -> ContractAddress { + contract_address_const::<'USER'>() + } + + pub fn USER2() -> ContractAddress { + contract_address_const::<'USER2'>() + } + + pub fn USER3() -> ContractAddress { + contract_address_const::<'USER3'>() + } + + pub fn NON_MEMBER() -> ContractAddress { + contract_address_const::<'NON_MEMBER'>() + } + + + fn deploy_user_management_contract() -> (ContractAddress, IUserManagementDispatcher) { + let user_management_class_hash = declare("MockUserManagementContract") + .unwrap() + .contract_class(); + let mut starkremit_constructor_calldata = array![]; + OWNER().serialize(ref starkremit_constructor_calldata); + let (contract_address, _) = user_management_class_hash + .deploy(@starkremit_constructor_calldata) + .unwrap(); + + let user_management_dispatcher = IUserManagementDispatcher { + contract_address: contract_address, + }; + + (contract_address, user_management_dispatcher) + } + + #[test] + fn test_user_registration_success() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let registration_request = RegistrationRequest { + email_hash: 'akinshola07@gmail.com', + phone_hash: '090999999', + full_name: 'Akin Shola', + country_code: '888', + }; + + let mut spy_events = spy_events(); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(registration_state == true, 'Registration should be enabled'); + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.register_user(registration_request); + stop_cheat_caller_address(contract_address); + + let user_registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(user_registration_status == RegistrationStatus::Completed, 'incorrect status'); + let user_profile = user_management_dispatcher.get_user_profile(USER()); + + let expected_user_profile = UserProfile { + address: USER(), + user_address: USER(), + email_hash: registration_request.email_hash, + phone_hash: registration_request.phone_hash, + full_name: registration_request.full_name, + kyc_level: KYCLevel::None, + registration_timestamp: get_block_timestamp(), + is_active: true, + country_code: registration_request.country_code, + }; + + assert(user_profile == expected_user_profile, 'incorrect user profile'); + let email_registry_address = user_management_dispatcher + .get_email_registry(registration_request.email_hash); + assert(email_registry_address == USER(), 'incorrect email registry'); + let phone_registry_address = user_management_dispatcher + .get_phone_registry(registration_request.phone_hash); + assert(phone_registry_address == USER(), 'incorrect phone registry'); + let total_users = user_management_dispatcher.get_total_users(); + assert(total_users == 1, 'incorrect total users'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserRegistered( + user_management_component::UserRegistered { + user_address: USER(), + email_hash: registration_request.email_hash, + registration_timestamp: get_block_timestamp(), + }, + ), + ), + ], + ); + } + + #[test] + fn test_update_user_profile_success() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let registration_request = RegistrationRequest { + email_hash: 'akinshola07@gmail.com', + phone_hash: '090999999', + full_name: 'Akin Shola', + country_code: '888', + }; + + let mut spy_events = spy_events(); + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.register_user(registration_request); + stop_cheat_caller_address(contract_address); + + let mut user_profile = user_management_dispatcher.get_user_profile(USER()); + + let expected_user_profile = UserProfile { + address: USER(), + user_address: USER(), + email_hash: registration_request.email_hash, + phone_hash: registration_request.phone_hash, + full_name: registration_request.full_name, + kyc_level: KYCLevel::None, + registration_timestamp: get_block_timestamp(), + is_active: true, + country_code: registration_request.country_code, + }; + + assert(user_profile == expected_user_profile, 'incorrect user profile'); + + let updated_user_profile = UserProfile { + address: USER(), + user_address: USER(), + email_hash: 'mynewemail@gmail.com', + phone_hash: '09123456789', + full_name: 'Akin Shola Updated', + kyc_level: KYCLevel::None, + registration_timestamp: get_block_timestamp(), + is_active: true, + country_code: '999', + }; + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.update_user_profile(updated_user_profile); + stop_cheat_caller_address(contract_address); + + let email_registry_address = user_management_dispatcher + .get_email_registry('mynewemail@gmail.com'); + assert(email_registry_address == USER(), 'incorrect email registry'); + + let phone_registry_address = user_management_dispatcher.get_phone_registry('09123456789'); + assert(phone_registry_address == USER(), 'incorrect phone registry'); + + let new_user_profile = user_management_dispatcher.get_user_profile(USER()); + assert(new_user_profile == updated_user_profile, 'incorrect user profile'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserProfileUpdated( + user_management_component::UserProfileUpdated { + user_address: USER(), + updated_fields: " email phone full_name country_code", + }, + ), + ), + ], + ) + } + + #[test] + fn test_successful_deactivate_user() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let registration_request = RegistrationRequest { + email_hash: 'akinshola07@gmail.com', + phone_hash: '090999999', + full_name: 'Akin Shola', + country_code: '888', + }; + + let mut spy_events = spy_events(); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(registration_state == true, 'Registration should be enabled'); + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.register_user(registration_request); + stop_cheat_caller_address(contract_address); + + let user_registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(user_registration_status == RegistrationStatus::Completed, 'incorrect status'); + let mut user_profile = user_management_dispatcher.get_user_profile(USER()); + assert(user_profile.is_active, 'incorrect user active status'); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, ADMIN()); + user_management_dispatcher.deactivate_user(USER()); + stop_cheat_caller_address(contract_address); + + let mut user_profile = user_management_dispatcher.get_user_profile(USER()); + assert(!user_profile.is_active, 'incorrect user active status'); + + let registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(registration_status == RegistrationStatus::Suspended, 'incorrect status'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserDeactivated( + user_management_component::UserDeactivated { + user_address: USER(), admin: ADMIN(), + }, + ), + ), + ], + ) + } + + + #[test] + fn test_successful_reactivate_user() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let registration_request = RegistrationRequest { + email_hash: 'akinshola07@gmail.com', + phone_hash: '090999999', + full_name: 'Akin Shola', + country_code: '888', + }; + + let mut spy_events = spy_events(); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(registration_state == true, 'Registration should be enabled'); + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.register_user(registration_request); + stop_cheat_caller_address(contract_address); + + let user_registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(user_registration_status == RegistrationStatus::Completed, 'incorrect status'); + let mut user_profile = user_management_dispatcher.get_user_profile(USER()); + assert(user_profile.is_active, 'incorrect user active status'); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, ADMIN()); + user_management_dispatcher.deactivate_user(USER()); + stop_cheat_caller_address(contract_address); + + user_profile = user_management_dispatcher.get_user_profile(USER()); + assert(!user_profile.is_active, 'user should be inactive'); + + let registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(registration_status == RegistrationStatus::Suspended, 'incorrect status'); + + start_cheat_caller_address(contract_address, ADMIN()); + user_management_dispatcher.reactivate_user(USER()); + stop_cheat_caller_address(contract_address); + + let registration_status = user_management_dispatcher.get_registration_status(USER()); + assert(registration_status == RegistrationStatus::Completed, 'incorrect status'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserReactivated( + user_management_component::UserReactivated { + user_address: USER(), admin: ADMIN(), + }, + ), + ), + ], + ) + } + + #[test] + fn test_add_user_admin() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let mut spy_events = spy_events(); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + let admin_status = user_management_dispatcher.get_admin_status(ADMIN()); + assert(admin_status, 'Admin should be added'); + + let list_of_admins: Array = user_management_dispatcher.get_admins(); + assert(list_of_admins.len() == 1, 'Admin should be in the list'); + assert(*list_of_admins.at(0) == ADMIN(), 'Admin address is incorrect'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserAdminAdded( + user_management_component::UserAdminAdded { + admin: ADMIN(), timestamp: get_block_timestamp(), + }, + ), + ), + ], + ) + } + + #[test] + fn test_remove_user_admin() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let mut spy_events = spy_events(); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN2()); + stop_cheat_caller_address(contract_address); + + let admin_status = user_management_dispatcher.get_admin_status(ADMIN()); + assert(admin_status, 'Admin should be added'); + + let list_of_admins: Array = user_management_dispatcher.get_admins(); + assert(list_of_admins.len() == 2, 'Admin should be in the list'); + assert(*list_of_admins.at(0) == ADMIN(), 'Admin address is incorrect'); + assert(*list_of_admins.at(1) == ADMIN2(), 'Admin2 address is incorrect'); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.remove_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + let admin_status = user_management_dispatcher.get_admin_status(ADMIN()); + assert(!admin_status, 'Admin should be removed'); + + let list_of_admins: Array = user_management_dispatcher.get_admins(); + assert(list_of_admins.len() == 0, 'Admin should not be in the list'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserAdminRemoved( + user_management_component::UserAdminRemoved { + removed_admin: ADMIN(), + new_admin_array: array![], + timestamp: get_block_timestamp(), + }, + ), + ), + ], + ) + } + + #[test] + fn test_pause_registration() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let mut spy_events = spy_events(); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.pause_registration(); + stop_cheat_caller_address(contract_address); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(!registration_state, 'Registration should be paused'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::RegistrationPaused( + user_management_component::RegistrationPaused { + timestamp: get_block_timestamp(), + }, + ), + ), + ], + ) + } + + + #[test] + fn test_resume_registration() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let mut spy_events = spy_events(); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.pause_registration(); + stop_cheat_caller_address(contract_address); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(!registration_state, 'Registration should be paused'); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.resume_registration(); + stop_cheat_caller_address(contract_address); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(registration_state, 'Registration should resume'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::RegistrationResumed( + user_management_component::RegistrationResumed { + timestamp: get_block_timestamp(), + }, + ), + ), + ], + ) + } + + #[test] + fn test_update_kyc_level_success() { + let (contract_address, user_management_dispatcher) = deploy_user_management_contract(); + + let registration_request = RegistrationRequest { + email_hash: 'akinshola07@gmail.com', + phone_hash: '090999999', + full_name: 'Akin Shola', + country_code: '888', + }; + + let mut spy_events = spy_events(); + + let registration_state = user_management_dispatcher.get_registration_state(); + assert(registration_state == true, 'Registration should be enabled'); + + start_cheat_caller_address(contract_address, USER()); + user_management_dispatcher.register_user(registration_request); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, OWNER()); + user_management_dispatcher.add_user_admin(ADMIN()); + stop_cheat_caller_address(contract_address); + + start_cheat_caller_address(contract_address, ADMIN()); + user_management_dispatcher.update_user_kyc(USER(), KYCLevel::Advanced); + stop_cheat_caller_address(contract_address); + + let kyc_level = user_management_dispatcher.get_user_kyc_level(USER()); + assert(kyc_level == KYCLevel::Advanced, 'KYC level should be updated'); + + spy_events + .assert_emitted( + @array![ + ( + contract_address, + user_management_component::Event::UserKYCUpdated( + user_management_component::UserKYCUpdated { + user_address: USER(), + new_kyc_level: KYCLevel::Advanced, + timestamp: get_block_timestamp(), + }, + ), + ), + ], + ) + } +} + diff --git a/src/component/user_management/user_management.cairo b/src/component/user_management/user_management.cairo new file mode 100644 index 0000000..9ecc80a --- /dev/null +++ b/src/component/user_management/user_management.cairo @@ -0,0 +1,462 @@ +use starknet::ContractAddress; +use starkremit_contract::base::types::{ + KYCLevel, RegistrationRequest, RegistrationStatus, UserProfile, +}; +#[starknet::interface] +pub trait IUserManagement { + fn register_user(ref self: TContractState, registration_data: RegistrationRequest) -> bool; + fn get_user_profile(self: @TContractState, user_address: ContractAddress) -> UserProfile; + fn update_user_profile(ref self: TContractState, updated_profile: UserProfile) -> bool; + fn is_user_registered(self: @TContractState, user_address: ContractAddress) -> bool; + fn get_registration_status( + self: @TContractState, user_address: ContractAddress, + ) -> RegistrationStatus; + fn deactivate_user(ref self: TContractState, user_address: ContractAddress) -> bool; + fn reactivate_user(ref self: TContractState, user_address: ContractAddress) -> bool; + fn get_total_users(self: @TContractState) -> u256; + fn add_user_admin(ref self: TContractState, admin_address: ContractAddress); + fn remove_user_admin(ref self: TContractState, admin_address: ContractAddress); + fn get_admins(self: @TContractState) -> Array; + fn get_admin_status(self: @TContractState, admin_address: ContractAddress) -> bool; + fn pause_registration(ref self: TContractState); + fn resume_registration(ref self: TContractState); + fn get_registration_state(self: @TContractState) -> bool; + fn update_user_kyc( + ref self: TContractState, user_address: ContractAddress, new_kyc_level: KYCLevel, + ); + fn get_user_kyc_level(self: @TContractState, user_address: ContractAddress) -> KYCLevel; + fn get_email_registry(self: @TContractState, email_hash: felt252) -> ContractAddress; + fn get_phone_registry(self: @TContractState, phone_hash: felt252) -> ContractAddress; +} + +#[starknet::component] +pub mod user_management_component { + use core::num::traits::Zero; + use openzeppelin::access::ownable::OwnableComponent; + use openzeppelin::access::ownable::OwnableComponent::OwnableImpl; + use starknet::storage::{ + Map, MutableVecTrait, StorageMapReadAccess, StorageMapWriteAccess, StoragePathEntry, + StoragePointerReadAccess, StoragePointerWriteAccess, Vec, VecTrait, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use starkremit_contract::base::errors::RegistrationErrors; + use starkremit_contract::base::types::{ + KYCLevel, RegistrationRequest, RegistrationStatus, UserProfile, + }; + use super::*; + + #[storage] + pub struct Storage { + user_profiles: Map, + user_admins: Vec, + email_registry: Map, + phone_registry: Map, + registration_status: Map, + total_users: u256, + registration_enabled: bool, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + UserRegistered: UserRegistered, + UserProfileUpdated: UserProfileUpdated, + UserDeactivated: UserDeactivated, + UserReactivated: UserReactivated, + UserAdminAdded: UserAdminAdded, + UserAdminRemoved: UserAdminRemoved, + UserKYCUpdated: UserKYCUpdated, + RegistrationPaused: RegistrationPaused, + RegistrationResumed: RegistrationResumed, + } + + #[derive(Drop, starknet::Event)] + pub struct UserRegistered { + pub user_address: ContractAddress, + pub email_hash: felt252, + pub registration_timestamp: u64, + } + #[derive(Drop, starknet::Event)] + pub struct UserProfileUpdated { + pub user_address: ContractAddress, + pub updated_fields: ByteArray, + } + #[derive(Drop, starknet::Event)] + pub struct UserDeactivated { + pub user_address: ContractAddress, + pub admin: ContractAddress, + } + #[derive(Drop, starknet::Event)] + pub struct UserReactivated { + pub user_address: ContractAddress, + pub admin: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct UserAdminAdded { + pub admin: ContractAddress, + pub timestamp: u64, + } + + + #[derive(Drop, starknet::Event)] + pub struct UserKYCUpdated { + pub user_address: ContractAddress, + pub new_kyc_level: KYCLevel, + pub timestamp: u64, + } + + + #[derive(Drop, starknet::Event)] + pub struct UserAdminRemoved { + pub removed_admin: ContractAddress, + pub new_admin_array: Array, + pub timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RegistrationPaused { + pub timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RegistrationResumed { + pub timestamp: u64, + } + + #[embeddable_as(UserManagement)] + pub impl UserManagementImpl< + TContractState, + +HasComponent, + +Drop, + impl Owner: OwnableComponent::HasComponent, + > of IUserManagement> { + fn add_user_admin( + ref self: ComponentState, admin_address: ContractAddress, + ) { + let caller = get_caller_address(); + let owner_comp = get_dep_component!(@self, Owner); + let owner = owner_comp.owner(); + let admin_status = self.get_admin_status(caller); + assert(caller == owner || admin_status, RegistrationErrors::NOT_USER_ADMIN); + + self.user_admins.push(admin_address); + + self + .emit( + Event::UserAdminAdded( + UserAdminAdded { admin: admin_address, timestamp: get_block_timestamp() }, + ), + ); + } + + fn remove_user_admin( + ref self: ComponentState, admin_address: ContractAddress, + ) { + let caller = get_caller_address(); + let owner_comp = get_dep_component!(@self, Owner); + let owner = owner_comp.owner(); + let admin_status = self.get_admin_status(caller); + assert(caller == owner || admin_status, RegistrationErrors::NOT_USER_ADMIN); + let mut array_of_admins: Array = array![]; + let user_admins_vec = self.user_admins; + let admin_array: Array = self.get_admins(); + for _ in 0..user_admins_vec.len() { + user_admins_vec.pop().unwrap(); + } + for address in admin_array { + if address != admin_address { + self.user_admins.push(address); + array_of_admins.append(address); + } + } + self + .emit( + Event::UserAdminRemoved( + UserAdminRemoved { + removed_admin: admin_address, + new_admin_array: array_of_admins, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn get_admin_status( + self: @ComponentState, admin_address: ContractAddress, + ) -> bool { + for i in 0..self.user_admins.len() { + let address = self.user_admins.at(i).read(); + if address == admin_address { + return true; + } + } + false + } + + fn get_admins(self: @ComponentState) -> Array { + let mut admins: Array = array![]; + for i in 0..self.user_admins.len() { + let address = self.user_admins.at(i).read(); + admins.append(address); + } + admins + } + + fn pause_registration(ref self: ComponentState) { + self.registration_enabled.write(false); + + self + .emit( + Event::RegistrationPaused( + RegistrationPaused { timestamp: get_block_timestamp() }, + ), + ); + } + + fn resume_registration(ref self: ComponentState) { + self.registration_enabled.write(true); + + self + .emit( + Event::RegistrationResumed( + RegistrationResumed { timestamp: get_block_timestamp() }, + ), + ); + } + + fn get_registration_state(self: @ComponentState) -> bool { + self.registration_enabled.read() + } + + fn register_user( + ref self: ComponentState, registration_data: RegistrationRequest, + ) -> bool { + let caller = get_caller_address(); + let registration_enabled = self.registration_enabled.read(); + assert(registration_enabled, RegistrationErrors::REGISTRATION_DISABLED); + + assert(!caller.is_zero(), RegistrationErrors::ZERO_ADDRESS); + // Check for duplicate email + let existing_email_user = self.email_registry.read(registration_data.email_hash); + assert(existing_email_user.is_zero(), RegistrationErrors::EMAIL_ALREADY_EXISTS); + // Check for duplicate phone + let existing_phone_user = self.phone_registry.read(registration_data.phone_hash); + assert(existing_phone_user.is_zero(), RegistrationErrors::PHONE_ALREADY_EXISTS); + + assert(registration_data.full_name != '', RegistrationErrors::INVALID_FULL_NAME); + assert( + registration_data.country_code.try_into().expect('Invalid code') != 0, + RegistrationErrors::INVALID_FULL_NAME, + ); + + // Set registration status to in progress + self.registration_status.write(caller, RegistrationStatus::InProgress); + // Create user profile + let current_timestamp = get_block_timestamp(); + let user_profile = UserProfile { + address: caller, + user_address: caller, + email_hash: registration_data.email_hash, + phone_hash: registration_data.phone_hash, + full_name: registration_data.full_name, + kyc_level: KYCLevel::None, + registration_timestamp: current_timestamp, + is_active: true, + country_code: registration_data.country_code, + }; + self.user_profiles.write(caller, user_profile); + self.email_registry.write(registration_data.email_hash, caller); + self.phone_registry.write(registration_data.phone_hash, caller); + self.registration_status.write(caller, RegistrationStatus::Completed); + let current_total = self.total_users.read(); + self.total_users.write(current_total + 1); + self + .emit( + Event::UserRegistered( + UserRegistered { + user_address: caller, + email_hash: registration_data.email_hash, + registration_timestamp: current_timestamp, + }, + ), + ); + true + } + fn get_email_registry( + self: @ComponentState, email_hash: felt252, + ) -> ContractAddress { + self.email_registry.read(email_hash) + } + + fn get_phone_registry( + self: @ComponentState, phone_hash: felt252, + ) -> ContractAddress { + self.phone_registry.read(phone_hash) + } + + fn get_user_profile( + self: @ComponentState, user_address: ContractAddress, + ) -> UserProfile { + let status = self.registration_status.read(user_address); + self.user_profiles.read(user_address) + } + fn update_user_profile( + ref self: ComponentState, updated_profile: UserProfile, + ) -> bool { + let caller = get_caller_address(); + assert(updated_profile.user_address == caller, 'Cannot update other profile'); + let status = self.registration_status.read(caller); + match status { + RegistrationStatus::Completed => {}, + _ => { assert(false, RegistrationErrors::USER_NOT_FOUND); }, + } + let current_profile = self.user_profiles.read(caller); + assert(current_profile.is_active, RegistrationErrors::USER_INACTIVE); + assert(updated_profile.address == current_profile.address, 'Cannot change address'); + assert( + updated_profile.registration_timestamp == current_profile.registration_timestamp, + 'Cannot change timestamp', + ); + if updated_profile.email_hash != current_profile.email_hash { + let zero_address: ContractAddress = Zero::zero(); + let existing_email_user = self.email_registry.read(updated_profile.email_hash); + assert(existing_email_user.is_zero(), RegistrationErrors::EMAIL_ALREADY_EXISTS); + self.email_registry.write(current_profile.email_hash, zero_address); + self.email_registry.write(updated_profile.email_hash, caller); + } + if updated_profile.phone_hash != current_profile.phone_hash { + let zero_address: ContractAddress = 0.try_into().unwrap(); + let existing_phone_user = self.phone_registry.read(updated_profile.phone_hash); + assert(existing_phone_user.is_zero(), RegistrationErrors::PHONE_ALREADY_EXISTS); + self.phone_registry.write(current_profile.phone_hash, zero_address); + self.phone_registry.write(updated_profile.phone_hash, caller); + } + self.user_profiles.write(caller, updated_profile); + + let mut changed_fields: ByteArray = ""; + if current_profile.user_address != updated_profile.user_address { + changed_fields += " address"; + } + if current_profile.email_hash != updated_profile.email_hash { + changed_fields += " email"; + } + if current_profile.phone_hash != updated_profile.phone_hash { + changed_fields += " phone"; + } + + if current_profile.full_name != updated_profile.full_name { + changed_fields += " full_name"; + } + if current_profile.country_code != updated_profile.country_code { + changed_fields += " country_code"; + } + self + .emit( + Event::UserProfileUpdated( + UserProfileUpdated { user_address: caller, updated_fields: changed_fields }, + ), + ); + true + } + + fn update_user_kyc( + ref self: ComponentState, + user_address: ContractAddress, + new_kyc_level: KYCLevel, + ) { + let caller = get_caller_address(); + assert(self.get_admin_status(caller), RegistrationErrors::NOT_USER_ADMIN); + let mut user_profile = self.user_profiles.read(user_address); + user_profile.kyc_level = new_kyc_level; + self.user_profiles.write(user_address, user_profile); + self + .emit( + Event::UserKYCUpdated( + UserKYCUpdated { + user_address, new_kyc_level, timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn get_user_kyc_level( + self: @ComponentState, user_address: ContractAddress, + ) -> KYCLevel { + let user_dets = self.user_profiles.entry(user_address).read(); + user_dets.kyc_level + } + + + fn is_user_registered( + self: @ComponentState, user_address: ContractAddress, + ) -> bool { + let status = self.registration_status.read(user_address); + match status { + RegistrationStatus::Completed => true, + _ => false, + } + } + fn get_registration_status( + self: @ComponentState, user_address: ContractAddress, + ) -> RegistrationStatus { + self.registration_status.read(user_address) + } + fn deactivate_user( + ref self: ComponentState, user_address: ContractAddress, + ) -> bool { + let caller = get_caller_address(); + assert(self.get_admin_status(caller), RegistrationErrors::NOT_USER_ADMIN); + let is_registered = match self.registration_status.read(user_address) { + RegistrationStatus::Completed => true, + _ => false, + }; + println!("User registration status: {:?}", is_registered); + assert(is_registered == true, RegistrationErrors::USER_NOT_FOUND); + let mut user_profile = self.user_profiles.read(user_address); + user_profile.is_active = false; + self.user_profiles.write(user_address, user_profile); + self.registration_status.write(user_address, RegistrationStatus::Suspended); + self.emit(Event::UserDeactivated(UserDeactivated { user_address, admin: caller })); + true + } + fn reactivate_user( + ref self: ComponentState, user_address: ContractAddress, + ) -> bool { + let caller = get_caller_address(); + assert(self.get_admin_status(caller), RegistrationErrors::NOT_USER_ADMIN); + let status = self.registration_status.read(user_address); + match status { + RegistrationStatus::Suspended => {}, + _ => { assert(false, 'User not suspended'); }, + } + let mut user_profile = self.user_profiles.read(user_address); + user_profile.is_active = true; + self.user_profiles.write(user_address, user_profile); + self.registration_status.write(user_address, RegistrationStatus::Completed); + self.emit(Event::UserReactivated(UserReactivated { user_address, admin: caller })); + true + } + fn get_total_users(self: @ComponentState) -> u256 { + self.total_users.read() + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +Drop, + impl Owner: OwnableComponent::HasComponent, + > of InternalTrait { + fn initializer(ref self: ComponentState) { + self.registration_enabled.write(true); + } + + fn is_owner(self: @ComponentState) { + let owner_comp = get_dep_component!(self, Owner); + let owner = owner_comp.owner(); + assert(owner == get_caller_address(), 'Caller is not the owner'); + } + } +} diff --git a/src/lib.cairo b/src/lib.cairo index ae92e66..d9ebf7b 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -33,5 +33,9 @@ pub mod component { pub mod savings_group; pub mod token_management; pub mod transfer; - pub mod user_management; + pub mod user_management { + pub mod mock; + pub mod test; + pub mod user_management; + } } diff --git a/src/starkremit/StarkRemit.cairo b/src/starkremit/StarkRemit.cairo index 2cf127a..0a3156b 100644 --- a/src/starkremit/StarkRemit.cairo +++ b/src/starkremit/StarkRemit.cairo @@ -35,7 +35,7 @@ pub mod StarkRemit { use starkremit_contract::component::savings_group::savings_group_component; use starkremit_contract::component::token_management::token_management_component; use starkremit_contract::component::transfer::transfer_component; - use starkremit_contract::component::user_management::user_management_component; + use starkremit_contract::component::user_management::user_management::user_management_component; use super::*; component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);