diff --git a/src/base/errors.cairo b/src/base/errors.cairo index b480de1..85da2ea 100644 --- a/src/base/errors.cairo +++ b/src/base/errors.cairo @@ -33,3 +33,5 @@ pub const ERROR_CONTRACT_PAUSED: felt252 = 'Contract is paused'; pub const ERROR_ALREADY_PAUSED: felt252 = 'Contract already paused'; pub const ERROR_INVALID_MILESTONE_DESCRIPTION: felt252 = 'Invalid milestone description'; pub const ERROR_INVALID_BUDGET: felt252 = 'Invalid budget'; +pub const ERROR_PROJECT_TERMINATED: felt252 = 'Project is terminated'; +pub const ERROR_PROJECT_ALREADY_TERMINATED: felt252 = 'Project already terminated'; diff --git a/src/budgetchain/Budget.cairo b/src/budgetchain/Budget.cairo index 11ccc27..d18f2b8 100644 --- a/src/budgetchain/Budget.cairo +++ b/src/budgetchain/Budget.cairo @@ -65,6 +65,7 @@ pub mod Budget { project_owners: LegacyMap, milestone_statuses: LegacyMap<(u64, u64), bool>, is_paused: bool, + project_status: Map // true = active, false = terminated } @@ -85,6 +86,7 @@ pub mod Budget { FundsRequested: FundsRequested, OrganizationRemoved: OrganizationRemoved, FundsReturned: FundsReturned, + ProjectTerminated: ProjectTerminated, } #[derive(Drop, starknet::Event)] @@ -162,6 +164,10 @@ pub mod Budget { pub org_id: u256, } + #[derive(Drop, starknet::Event)] + pub struct ProjectTerminated { + pub project_id: u64, + } #[constructor] fn constructor(ref self: ContractState, default_admin: ContractAddress) { assert(default_admin != contract_address_const::<0>(), ERROR_ZERO_ADDRESS); @@ -191,6 +197,9 @@ pub mod Budget { // Ensure the contract is not paused self.assert_not_paused(); + // Ensure the project is active + self.assert_project_active(project_id); + // Generate new transaction ID let transaction_id = self.transaction_count.read(); let sender = get_caller_address(); @@ -334,6 +343,9 @@ pub mod Budget { assert(project_id != 0, 'Invalid project ID'); assert(amount > 0, 'Amount cannot be zero'); + // Ensure the project is active + self.assert_project_active(project_id); + let get_project_by_id = self.projects.read(project_id); assert(get_project_by_id.owner == project_owner, ERROR_UNAUTHORIZED); @@ -474,6 +486,9 @@ pub mod Budget { // Emit event self.emit(ProjectAllocated { project_id, org, project_owner, total_budget }); + // Set project status to be true + self.project_status.write(project_id, true); + project_id } @@ -524,8 +539,11 @@ pub mod Budget { let admin = self.admin.read(); assert(admin == get_caller_address(), ERROR_ONLY_ADMIN); + // Ensure the project is active + self.assert_project_active(project_id); + let created_at = get_block_timestamp(); - + let new_milestone: Milestone = Milestone { organization: org, project_id: project_id, @@ -649,6 +667,10 @@ pub mod Budget { ) { // Ensure the contract is not paused self.assert_not_paused(); + + // Ensure the project is active + self.assert_project_active(project_id); + // Verify caller is an authorized organization self.accesscontrol.assert_only_role(ORGANIZATION_ROLE); @@ -817,6 +839,9 @@ pub mod Budget { //Ensure the contract is not paused self.assert_not_paused(); + // Ensure the project is active + self.assert_project_active(project_id); + // Verify project exists let project = self.projects.read(project_id); assert(project.org != contract_address_const::<0>(), ERROR_INVALID_PROJECT_ID); @@ -874,8 +899,35 @@ pub mod Budget { request_id } + + fn is_project_active(self: @ContractState, project_id: u64) -> bool { + // Check that project exists + let project = self.projects.read(project_id); + assert(project.org != contract_address_const::<0>(), ERROR_INVALID_PROJECT_ID); + // Check if project is active + self.project_status.read(project_id) + } + + fn terminate_project(ref self: ContractState, project_id: u64) { + // Only admin can terminate project + self.accesscontrol.assert_only_role(ADMIN_ROLE); + // Check that the project exists + let project = self.projects.read(project_id); + assert(project.org != contract_address_const::<0>(), ERROR_INVALID_PROJECT_ID); + + // Check if project is terminated + let is_active = self.project_status.read(project_id); + assert(is_active != false, ERROR_PROJECT_ALREADY_TERMINATED); + + // Terminate the project + self.project_status.write(project_id, false); + + // Emit event + self.emit(Event::ProjectTerminated(ProjectTerminated { project_id })); + } } + #[generate_trait] pub impl Internal of InternalTrait { // Internal view function @@ -884,5 +936,14 @@ pub mod Budget { fn assert_not_paused(self: @ContractState) { assert(!self.is_paused.read(), ERROR_CONTRACT_PAUSED); } + // Internal helper function + fn assert_project_active(self: @ContractState, project_id: u64) { + //Check that project exists + let project = self.projects.read(project_id); + assert(project.org != contract_address_const::<0>(), ERROR_INVALID_PROJECT_ID); + //check if project is active + let is_active = self.project_status.read(project_id) == false; + assert(!is_active, ERROR_PROJECT_TERMINATED); + } } } diff --git a/src/interfaces/IBudget.cairo b/src/interfaces/IBudget.cairo index 5cf0703..aa4bff6 100644 --- a/src/interfaces/IBudget.cairo +++ b/src/interfaces/IBudget.cairo @@ -99,4 +99,6 @@ pub trait IBudget { fn pause_contract(ref self: TContractState); fn unpause_contract(ref self: TContractState); fn is_paused(self: @TContractState) -> bool; + fn terminate_project(ref self: TContractState, project_id: u64); + fn is_project_active(self: @TContractState, project_id: u64) -> bool; } diff --git a/tests/test_budgetchain.cairo b/tests/test_budgetchain.cairo index 1ee7620..040de0a 100644 --- a/tests/test_budgetchain.cairo +++ b/tests/test_budgetchain.cairo @@ -1980,3 +1980,223 @@ fn test_allocate_project_event() { stop_cheat_caller_address(org_address); } + +#[test] +fn test_project_active_status() { + // Setup project with milestones + let (contract_address, _, _, _, project_id, _) = setup_project_with_milestones(); + let budget_dispatcher = IBudgetDispatcher { contract_address }; + // Test that project is active after creation + let is_active = budget_dispatcher.is_project_active(project_id); + assert(is_active == true, 'Project is not active'); +} + +#[test] +#[should_panic(expected: 'Invalid project ID')] +fn test_project_active_status_for_non_existent_project() { + // Setup project with milestones + let (contract_address, _, _, _, _, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + let non_project = 9999; + let is_active = budget_dispatcher.is_project_active(non_project); + assert(is_active == true, 'Project is not active'); +} + +#[test] +fn test_project_terminate_as_admin() { + // Setup project with milestones + let (contract_address, admin_address, _, _, project_id, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); +} + +#[test] +fn test_project_should_be_inactive_after_termination() { + // Setup project with milestones + let (contract_address, admin_address, _, _, project_id, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + + // Verify the project is now inactive + let is_active = budget_dispatcher.is_project_active(project_id); + assert(is_active == false, 'Project meant to be terminated'); +} + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_project_terminate_as_non_admin() { + // Setup project with milestones + let (contract_address, _, _, _, project_id, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + + let non_admin = contract_address_const::<'non_admin'>(); + + cheat_caller_address(contract_address, non_admin, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + // Try to terminate the project as non-admin + stop_cheat_caller_address(non_admin); +} + +#[test] +#[should_panic(expected: 'Project already terminated')] +fn test_attempt_project_terminate_for_already_terminated_project() { + // Setup project with milestones + let (contract_address, admin_address, _, _, project_id, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + + //Attempt to terminate again + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); +} + +#[test] +#[should_panic(expected: 'Invalid project ID')] +fn test_attempt_to_terminate_non_existent_project() { + // Setup project with milestones + let (contract_address, admin_address, _, _, _, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + // Terminate project as admin + let non_project = 9999; + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(non_project); + stop_cheat_caller_address(admin_address); +} + +// Test for emmission of event when project is terminated +#[test] +fn test_terminate_project_event_emission() { + // Setup project with milestones + let (contract_address, admin_address, _, _, project_id, _) = setup_project_with_milestones(); + + let budget_dispatcher = IBudgetDispatcher { contract_address }; + + let mut spy = spy_events(); + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + + //Check the event emission + spy + .assert_emitted( + @array![ + ( + contract_address, + Budget::Budget::Event::ProjectTerminated( + Budget::Budget::ProjectTerminated { project_id }, + ), + ), + ], + ); +} + + +#[test] +#[should_panic(expected: 'Project is terminated')] +fn test_attempt_to_create_milestone_for_terminated_project() { + // Setup project with milestones + let (contract_address, admin_address, org_address, project_owner, project_id, total_budget) = + setup_project_with_milestones(); + let budget_dispatcher = IBudgetDispatcher { contract_address }; + + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + + // Create milestone + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + let milestone_id = budget_dispatcher + .create_milestone(org_address, project_id, 'Test Milestone', total_budget); + stop_cheat_caller_address(admin_address); + + // Mark milestone as complete + cheat_caller_address(contract_address, project_owner, CheatSpan::Indefinite); + budget_dispatcher.set_milestone_complete(project_id, milestone_id); + stop_cheat_caller_address(project_owner); + +} + + +#[test] +#[should_panic(expected: 'Project is terminated')] +fn test_attempt_to_request_funds_for_terminated_project() { + // Setup project with milestones + let (contract_address, admin_address, org_address, project_owner, project_id, total_budget) = + setup_project_with_milestones(); + let budget_dispatcher = IBudgetDispatcher { contract_address }; + + // Create milestone + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + let milestone_id = budget_dispatcher + .create_milestone(org_address, project_id, 'Test Milestone', total_budget); + stop_cheat_caller_address(admin_address); + + // Mark milestone as complete + cheat_caller_address(contract_address, project_owner, CheatSpan::Indefinite); + budget_dispatcher.set_milestone_complete(project_id, milestone_id); + stop_cheat_caller_address(project_owner); + + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + + // Create fund request + cheat_caller_address(contract_address, project_owner, CheatSpan::Indefinite); + budget_dispatcher.request_funds(project_owner, project_id, milestone_id, 1); + stop_cheat_caller_address(project_owner); + +} + + +#[test] +#[should_panic(expected: 'Project is terminated')] +fn test_attempt_to_release_funds_for_terminated_project() { + // Setup project with milestones + let (contract_address, admin_address, org_address, project_owner, project_id, total_budget) = + setup_project_with_milestones(); + let budget_dispatcher = IBudgetDispatcher { contract_address }; + + // Create milestone + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + let milestone_id = budget_dispatcher + .create_milestone(org_address, project_id, 'Test Milestone', total_budget); + stop_cheat_caller_address(admin_address); + + // Mark milestone as complete + cheat_caller_address(contract_address, project_owner, CheatSpan::Indefinite); + budget_dispatcher.set_milestone_complete(project_id, milestone_id); + stop_cheat_caller_address(project_owner); + + // Create fund request + cheat_caller_address(contract_address, project_owner, CheatSpan::Indefinite); + let request_id = budget_dispatcher.create_fund_request(project_id, milestone_id); + stop_cheat_caller_address(project_owner); + + // Terminate project as admin + cheat_caller_address(contract_address, admin_address, CheatSpan::Indefinite); + budget_dispatcher.terminate_project(project_id); + stop_cheat_caller_address(admin_address); + // Release funds + cheat_caller_address(contract_address, org_address, CheatSpan::Indefinite); + budget_dispatcher.release_funds(org_address, project_id, request_id); + stop_cheat_caller_address(org_address); +} \ No newline at end of file