Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/base/errors.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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';
63 changes: 62 additions & 1 deletion src/budgetchain/Budget.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub mod Budget {
project_owners: LegacyMap<u64, ContractAddress>,
milestone_statuses: LegacyMap<(u64, u64), bool>,
is_paused: bool,
project_status: Map<u64, bool> // true = active, false = terminated
}


Expand All @@ -85,6 +86,7 @@ pub mod Budget {
FundsRequested: FundsRequested,
OrganizationRemoved: OrganizationRemoved,
FundsReturned: FundsReturned,
ProjectTerminated: ProjectTerminated,
}

#[derive(Drop, starknet::Event)]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
}
2 changes: 2 additions & 0 deletions src/interfaces/IBudget.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,6 @@ pub trait IBudget<TContractState> {
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;
}
220 changes: 220 additions & 0 deletions tests/test_budgetchain.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading