diff --git a/Scarb.lock b/Scarb.lock new file mode 100644 index 0000000..629fabb --- /dev/null +++ b/Scarb.lock @@ -0,0 +1,78 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "budget_contract" +version = "0.1.0" +dependencies = [ + "openzeppelin_access", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", + "snforge_std", +] + +[[package]] +name = "openzeppelin_access" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:7734901a0ca7a7065e69416fea615dd1dc586c8dc9e76c032f25ee62e8b2a06c" +dependencies = [ + "openzeppelin_introspection", +] + +[[package]] +name = "openzeppelin_account" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:1aa3a71e2f40f66f98d96aa9bf9f361f53db0fd20fa83ef7df04426a3c3a926a" +dependencies = [ + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_introspection" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:13e04a2190684e6804229a77a6c56de7d033db8b9ef519e5e8dee400a70d8a3d" + +[[package]] +name = "openzeppelin_token" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:4452f449dc6c1ea97cf69d1d9182749abd40e85bd826cd79652c06a627eafd91" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_upgrades" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:15fdd63f6b50a0fda7b3f8f434120aaf7637bcdfe6fd8d275ad57343d5ede5e1" + +[[package]] +name = "openzeppelin_utils" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:44f32d242af1e43982decc49c563e613a9b67ade552f5c3d5cde504e92f74607" + +[[package]] +name = "snforge_scarb_plugin" +version = "0.44.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:ec8c7637b33392a53153c1e5b87a4617ddcb1981951b233ea043cad5136697e2" + +[[package]] +name = "snforge_std" +version = "0.44.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:d4affedfb90715b1ac417b915c0a63377ae6dd69432040e5d933130d65114702" +dependencies = [ + "snforge_scarb_plugin", +] diff --git a/Scarb.toml b/Scarb.toml new file mode 100644 index 0000000..e5f398d --- /dev/null +++ b/Scarb.toml @@ -0,0 +1,18 @@ +[package] +name = "budget_contract" +version = "0.1.0" +edition = "2024_07" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +starknet = "2.9.2" +openzeppelin_access = "0.20.0" +openzeppelin_introspection = "0.20.0" +openzeppelin_token = "0.20.0" +openzeppelin_upgrades = "0.20.0" + +[dev-dependencies] +assert_macros = "2.9.2" +openzeppelin_utils = "0.20.0" +snforge_std = "0.44.0" \ No newline at end of file diff --git a/src/lib.cairo b/src/lib.cairo new file mode 100644 index 0000000..afefa9a --- /dev/null +++ b/src/lib.cairo @@ -0,0 +1 @@ +pub mod project_manager; diff --git a/src/project_manager.cairo b/src/project_manager.cairo new file mode 100644 index 0000000..d81c971 --- /dev/null +++ b/src/project_manager.cairo @@ -0,0 +1,144 @@ +use starknet::{ContractAddress, get_caller_address}; + +#[starknet::interface] +pub trait IProjectManager { + fn authorize_organization(ref self: TContractState, org: ContractAddress); + fn revoke_organization(ref self: TContractState, org: ContractAddress); + fn create_project( + ref self: TContractState, project_owner: ContractAddress, total_budget: u256, + ) -> u64; + fn get_project(self: @TContractState, id: u64) -> Project; + fn get_project_count(self: @TContractState) -> u64; + fn has_project_creator_role(self: @TContractState, org: ContractAddress) -> bool; +} + +#[derive(Drop, Copy, starknet::Store, Serde)] +pub struct Project { + pub id: u64, + pub org: ContractAddress, + pub owner: ContractAddress, + pub total_budget: u256, + pub remaining_budget: u256, +} + +#[starknet::contract] +pub mod ProjectManager { + use core::num::traits::Zero; + use openzeppelin_access::accesscontrol::{AccessControlComponent, DEFAULT_ADMIN_ROLE}; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use super::{ContractAddress, IProjectManager, Project, get_caller_address}; + + const PROJECT_CREATOR_ROLE: felt252 = selector!("PROJECT_CREATOR_ROLE"); + + component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + #[abi(embed_v0)] + impl AccessControlMixinImpl = + AccessControlComponent::AccessControlMixinImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + + // Removed erroneous SRC5MixinImpl implementation as the trait or impl was not found. + impl SRC5InternalImpl = SRC5Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + accesscontrol: AccessControlComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + project_count: u64, + projects: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + ProjectAllocated: ProjectAllocated, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[derive(Drop, starknet::Event)] + pub struct ProjectAllocated { + pub project_id: u64, + pub org: ContractAddress, + pub project_owner: ContractAddress, + pub total_budget: u256, + } + + #[constructor] + fn constructor(ref self: ContractState) { + let caller = get_caller_address(); + self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, caller); + self.accesscontrol._grant_role(PROJECT_CREATOR_ROLE, caller); + self.project_count.write(0); + } + + #[abi(embed_v0)] + pub impl ProjectManagerImpl of IProjectManager { + fn authorize_organization(ref self: ContractState, org: ContractAddress) { + let _caller = get_caller_address(); + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol.grant_role(PROJECT_CREATOR_ROLE, org); + } + + fn revoke_organization(ref self: ContractState, org: ContractAddress) { + let _caller = get_caller_address(); + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol.revoke_role(PROJECT_CREATOR_ROLE, org); + } + + fn create_project( + ref self: ContractState, project_owner: ContractAddress, total_budget: u256, + ) -> u64 { + let caller = get_caller_address(); + self.accesscontrol.assert_only_role(PROJECT_CREATOR_ROLE); + assert(!project_owner.is_zero(), 'Owner cannot be zero'); + + let current_id = self.project_count.read(); + let new_id = current_id; + + let project = Project { + id: new_id, + org: caller, + owner: project_owner, + total_budget, + remaining_budget: total_budget, + }; + + self.projects.write(new_id, project); + self.project_count.write(new_id + 1); + + self + .emit( + Event::ProjectAllocated( + ProjectAllocated { + project_id: new_id, org: caller, project_owner, total_budget, + }, + ), + ); + + new_id + } + + fn get_project(self: @ContractState, id: u64) -> Project { + assert(id < self.project_count.read(), 'Invalid project ID'); + self.projects.read(id) + } + + fn get_project_count(self: @ContractState) -> u64 { + self.project_count.read() + } + + fn has_project_creator_role(self: @ContractState, org: ContractAddress) -> bool { + self.accesscontrol.has_role(PROJECT_CREATOR_ROLE, org) + } + } +} diff --git a/tests/projectManagerTest.cairo b/tests/projectManagerTest.cairo new file mode 100644 index 0000000..c9f4a7a --- /dev/null +++ b/tests/projectManagerTest.cairo @@ -0,0 +1,169 @@ +use budget_contract::project_manager::ProjectManager::{ + Event as ProjectManagerEvents, ProjectAllocated, +}; +use budget_contract::project_manager::{ + IProjectManagerDispatcher, IProjectManagerDispatcherTrait, Project, +}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, spy_events, + start_cheat_caller_address_global, stop_cheat_caller_address_global, +}; +use starknet::contract_address::ContractAddress; +use starknet::contract_address_const; // Import for deterministic addresses +const PROJECT_CREATOR_ROLE: felt252 = selector!("PROJECT_CREATOR_ROLE"); +const DEFAULT_ADMIN_ROLE: felt252 = selector!("DEFAULT_ADMIN_ROLE"); + + +// Helper functions for deterministic test addresses +fn owner() -> ContractAddress { + contract_address_const::<'owner'>() +} +fn non_creator() -> ContractAddress { + contract_address_const::<'non_creator'>() +} +fn project_owner() -> ContractAddress { + contract_address_const::<'project_owner'>() +} +fn project_owner2() -> ContractAddress { + contract_address_const::<'project_owner2'>() +} +fn project_owner3() -> ContractAddress { + contract_address_const::<'project_owner3'>() +} + +fn deploy(owner: ContractAddress) -> IProjectManagerDispatcher { + let contract = declare("project_manager").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@array![owner.into()]).unwrap(); + IProjectManagerDispatcher { contract_address } +} + +#[test] +fn test_project_creation() { + let owner = owner(); + start_cheat_caller_address_global(owner); + + let contract = deploy(owner); + + let project_owner = project_owner(); + let total_budget: u256 = 1000_u256; + + // Create project as owner (who has PROJECT_CREATOR_ROLE) + let project_id = contract.create_project(project_owner, total_budget); + + // Fetch project and check fields + let project: Project = contract.get_project(project_id); + assert_eq!(project.id, project_id); + assert_eq!(project.org, owner); + assert_eq!(project.owner, project_owner); + assert_eq!(project.total_budget, total_budget); + assert_eq!(project.remaining_budget, total_budget); + + // Project count should be 1 + let count = contract.get_project_count(); + assert_eq!(count, 1_u64); +} + +#[test] +fn test_budget_update_logic() { + let owner = owner(); + start_cheat_caller_address_global(owner); + + let contract = deploy(owner); + + let project_owner = project_owner2(); + let total_budget: u256 = 500_u256; + + let project_id = contract.create_project(project_owner, total_budget); + + // Check that remaining_budget is initialized correctly + let project: Project = contract.get_project(project_id); + assert_eq!(project.remaining_budget, total_budget); + // If you add a function to update budget, test it here. +// For now, just check initialization. +} + +#[test] +#[should_panic(expected: 'Caller is not authorized')] +fn test_access_control_create_project_unauthorized() { + let owner = owner(); + let non_creator = non_creator(); + let project_owner = project_owner3(); + let total_budget: u256 = 100_u256; + + start_cheat_caller_address_global(owner); + let contract = deploy(owner); + + // Try to create a project from a non-authorized address + stop_cheat_caller_address_global(); + start_cheat_caller_address_global(non_creator); + contract.create_project(project_owner, total_budget); +} + +#[test] +fn test_access_control_grant_and_revoke() { + let owner = owner(); + let non_creator = non_creator(); + let project_owner = project_owner3(); + let total_budget: u256 = 100_u256; + + start_cheat_caller_address_global(owner); + let contract = deploy(owner); + + // Grant PROJECT_CREATOR_ROLE to non_creator + contract.authorize_organization(non_creator); + + // Now non_creator should be able to create a project + stop_cheat_caller_address_global(); + start_cheat_caller_address_global(non_creator); + let project_id = contract.create_project(project_owner, total_budget); + let project: Project = contract.get_project(project_id); + assert_eq!(project.org, non_creator); + + // Revoke role and check access is denied again + stop_cheat_caller_address_global(); + start_cheat_caller_address_global(owner); + contract.revoke_organization(non_creator); +} + +#[test] +#[should_panic(expected: 'Caller is not authorized')] +fn test_access_control_create_project_revoked() { + let owner = owner(); + let non_creator = non_creator(); + let project_owner = project_owner3(); + let total_budget: u256 = 100_u256; + + start_cheat_caller_address_global(owner); + let contract = deploy(owner); + contract.authorize_organization(non_creator); + contract.revoke_organization(non_creator); + stop_cheat_caller_address_global(); + start_cheat_caller_address_global(non_creator); + contract.create_project(project_owner, total_budget); +} +#[test] +fn test_project_allocated_event() { + let owner = owner(); + start_cheat_caller_address_global(owner); + + let contract = deploy(owner); + + let project_owner = project_owner3(); + let total_budget: u256 = 777_u256; + + let mut spy = spy_events(); + + let project_id = contract.create_project(project_owner, total_budget); + + spy + .assert_emitted( + @array![ + ( + contract.contract_address, + ProjectManagerEvents::ProjectAllocated( + ProjectAllocated { project_id, org: owner, project_owner, total_budget }, + ), + ), + ], + ); +}