From cc434969dcb3740729e45a49fa89f28acc27f452 Mon Sep 17 00:00:00 2001 From: caxtonacollins Date: Mon, 9 Jun 2025 11:05:00 +0100 Subject: [PATCH] removed submodule --- onchain/milestone_manager/.gitignore | 5 + onchain/milestone_manager/Scarb.lock | 143 ++++++++ onchain/milestone_manager/Scarb.toml | 54 +++ onchain/milestone_manager/snfoundry.toml | 11 + .../milestone_manager/src/base/errors.cairo | 8 + .../milestone_manager/src/base/types.cairo | 24 ++ .../src/budgetchain/MilestoneManager.cairo | 201 +++++++++++ .../src/interfaces/IMilestoneManager.cairo | 26 ++ onchain/milestone_manager/src/lib.cairo | 17 + .../tests/test_contract.cairo | 312 ++++++++++++++++++ 10 files changed, 801 insertions(+) create mode 100644 onchain/milestone_manager/.gitignore create mode 100644 onchain/milestone_manager/Scarb.lock create mode 100644 onchain/milestone_manager/Scarb.toml create mode 100644 onchain/milestone_manager/snfoundry.toml create mode 100644 onchain/milestone_manager/src/base/errors.cairo create mode 100644 onchain/milestone_manager/src/base/types.cairo create mode 100644 onchain/milestone_manager/src/budgetchain/MilestoneManager.cairo create mode 100644 onchain/milestone_manager/src/interfaces/IMilestoneManager.cairo create mode 100644 onchain/milestone_manager/src/lib.cairo create mode 100644 onchain/milestone_manager/tests/test_contract.cairo diff --git a/onchain/milestone_manager/.gitignore b/onchain/milestone_manager/.gitignore new file mode 100644 index 0000000..4096f8b --- /dev/null +++ b/onchain/milestone_manager/.gitignore @@ -0,0 +1,5 @@ +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/onchain/milestone_manager/Scarb.lock b/onchain/milestone_manager/Scarb.lock new file mode 100644 index 0000000..d282cf5 --- /dev/null +++ b/onchain/milestone_manager/Scarb.lock @@ -0,0 +1,143 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "milestone_manager" +version = "0.1.0" +dependencies = [ + "openzeppelin", + "snforge_std", +] + +[[package]] +name = "openzeppelin" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:05fd9365be85a4a3e878135d5c52229f760b3861ce4ed314cb1e75b178b553da" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_governance", + "openzeppelin_introspection", + "openzeppelin_merkle_tree", + "openzeppelin_presets", + "openzeppelin_security", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[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_finance" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:f0c507fbff955e4180ea3fa17949c0ff85518c40101f4948948d9d9a74143d6c" +dependencies = [ + "openzeppelin_access", + "openzeppelin_token", +] + +[[package]] +name = "openzeppelin_governance" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:c0fb60fad716413d537fabd5fcbb2c499ca6beb95af5f0d1699955ecec4c6f63" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_introspection" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:13e04a2190684e6804229a77a6c56de7d033db8b9ef519e5e8dee400a70d8a3d" + +[[package]] +name = "openzeppelin_merkle_tree" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:039608900e92f3dcf479bf53a49a1fd76452acd97eb86e390d1eb92cacdaf3af" + +[[package]] +name = "openzeppelin_presets" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:5c07a8de32e5d9abe33988c7927eaa8b5f83bc29dc77302d9c8c44c898611042" +dependencies = [ + "openzeppelin_access", + "openzeppelin_account", + "openzeppelin_finance", + "openzeppelin_introspection", + "openzeppelin_token", + "openzeppelin_upgrades", + "openzeppelin_utils", +] + +[[package]] +name = "openzeppelin_security" +version = "0.20.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:27155597019ecf971c48d7bfb07fa58cdc146d5297745570071732abca17f19f" + +[[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.43.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:178e1e2081003ae5e40b5a8574654bed15acbd31cce651d4e74fe2f009bc0122" + +[[package]] +name = "snforge_std" +version = "0.43.1" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:17bc65b0abfb9b174784835df173f9c81c9ad39523dba760f30589ef53cf8bb5" +dependencies = [ + "snforge_scarb_plugin", +] diff --git a/onchain/milestone_manager/Scarb.toml b/onchain/milestone_manager/Scarb.toml new file mode 100644 index 0000000..a20d09f --- /dev/null +++ b/onchain/milestone_manager/Scarb.toml @@ -0,0 +1,54 @@ +[package] +name = "milestone_manager" +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.11.4" +openzeppelin = "0.20.0" + +[dev-dependencies] +snforge_std = "0.43.0" +assert_macros = "2.11.4" +cairo_test = "2.11.4" + +[[target.starknet-contract]] +sierra = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] + +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/scarb-toml.html for more information + +# [tool.snforge] # Define `snforge` tool section +# exit_first = true # Stop tests execution immediately upon the first failure +# fuzzer_runs = 1234 # Number of runs of the random fuzzer +# fuzzer_seed = 1111 # Seed for the random fuzzer + +# [[tool.snforge.fork]] # Used for fork testing +# name = "SOME_NAME" # Fork name +# url = "http://your.rpc.url" # Url of the RPC provider +# block_id.tag = "latest" # Block to fork from (block tag) + +# [[tool.snforge.fork]] +# name = "SOME_SECOND_NAME" +# url = "http://your.second.rpc.url" +# block_id.number = "123" # Block to fork from (block number) + +# [[tool.snforge.fork]] +# name = "SOME_THIRD_NAME" +# url = "http://your.third.rpc.url" +# block_id.hash = "0x123" # Block to fork from (block hash) + +# [profile.dev.cairo] # Configure Cairo compiler +# unstable-add-statements-code-locations-debug-info = true # Should be used if you want to use coverage +# unstable-add-statements-functions-debug-info = true # Should be used if you want to use coverage/profiler +# inlining-strategy = "avoid" # Should be used if you want to use coverage + +# [features] # Used for conditional compilation +# enable_for_tests = [] # Feature name and list of other features that should be enabled with it diff --git a/onchain/milestone_manager/snfoundry.toml b/onchain/milestone_manager/snfoundry.toml new file mode 100644 index 0000000..78c7789 --- /dev/null +++ b/onchain/milestone_manager/snfoundry.toml @@ -0,0 +1,11 @@ +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/snfoundry-toml.html +# and https://foundry-rs.github.io/starknet-foundry/projects/configuration.html for more information + +# [sncast.default] # Define a profile name +# url = "https://free-rpc.nethermind.io/sepolia-juno/v0_8" # Url of the RPC provider +# accounts-file = "../account-file" # Path to the file with the account data +# account = "mainuser" # Account from `accounts_file` or default account file that will be used for the transactions +# keystore = "~/keystore" # Path to the keystore file +# wait-params = { timeout = 300, retry-interval = 10 } # Wait for submitted transaction parameters +# block-explorer = "StarkScan" # Block explorer service used to display links to transaction details +# show-explorer-links = true # Print links pointing to pages with transaction details in the chosen block explorer diff --git a/onchain/milestone_manager/src/base/errors.cairo b/onchain/milestone_manager/src/base/errors.cairo new file mode 100644 index 0000000..bb9d092 --- /dev/null +++ b/onchain/milestone_manager/src/base/errors.cairo @@ -0,0 +1,8 @@ +pub const ERROR_ONLY_ADMIN: felt252 = 'ONLY ADMIN'; +pub const ERROR_UNAUTHORIZED: felt252 = 'Caller not authorized'; +pub const ERROR_INVALID_MILESTONE: felt252 = 'Invalid milestone'; +pub const ERROR_INVALID_PROJECT_ID: felt252 = 'Invalid project ID'; +pub const ERROR_ZERO_ADDRESS: felt252 = 'Zero address forbidden'; +pub const ERROR_MILESTONE_ALREADY_COMPLETED: felt252 = 'Milestone already completed'; +pub const ERROR_CONTRACT_PAUSED: felt252 = 'Contract is paused'; +pub const ERROR_ALREADY_PAUSED: felt252 = 'Contract already paused'; diff --git a/onchain/milestone_manager/src/base/types.cairo b/onchain/milestone_manager/src/base/types.cairo new file mode 100644 index 0000000..7415a4b --- /dev/null +++ b/onchain/milestone_manager/src/base/types.cairo @@ -0,0 +1,24 @@ +use starknet::ContractAddress; + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct Project { + pub id: u64, + pub org: ContractAddress, + pub owner: ContractAddress, + pub total_budget: u256, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct Milestone { + pub project_id: u64, + pub milestone_id: u64, + pub organization: ContractAddress, + pub milestone_description: felt252, + pub milestone_amount: u256, + pub created_at: u64, + pub completed: bool, + pub released: bool, +} +// ROLE CONSTANTS +pub const ADMIN_ROLE: felt252 = selector!("ADMIN_ROLE"); +pub const ORGANIZATION_ROLE: felt252 = selector!("ORGANIZATION_ROLE"); diff --git a/onchain/milestone_manager/src/budgetchain/MilestoneManager.cairo b/onchain/milestone_manager/src/budgetchain/MilestoneManager.cairo new file mode 100644 index 0000000..20345d9 --- /dev/null +++ b/onchain/milestone_manager/src/budgetchain/MilestoneManager.cairo @@ -0,0 +1,201 @@ +#[starknet::contract] +#[feature("deprecated-starknet-consts")] +pub mod MilestoneManager { + use core::array::{Array, ArrayTrait}; + use milestone_manager::base::errors::*; + use milestone_manager::base::types::{ADMIN_ROLE, Milestone, ORGANIZATION_ROLE, Project}; + use milestone_manager::interfaces::IMilestoneManager::IMilestoneManager; + use openzeppelin::access::accesscontrol::{AccessControlComponent, DEFAULT_ADMIN_ROLE}; + use openzeppelin::introspection::src5::SRC5Component; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ + ContractAddress, contract_address_const, get_block_timestamp, get_caller_address, + }; + component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + // AccessControl Mixin + #[abi(embed_v0)] + impl AccessControlImpl = + AccessControlComponent::AccessControlImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + + // SRC5 Mixin + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + admin: ContractAddress, + projects: Map, + milestones: Map<(u64, u64), Milestone>, // (project_id, milestone_id) -> Milestone + project_milestone_count: Map, // project_id -> count of milestones + is_paused: bool, + #[substorage(v0)] + accesscontrol: AccessControlComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + MilestoneCreated: MilestoneCreated, + MilestoneCompleted: MilestoneCompleted, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[derive(Drop, starknet::Event)] + pub struct MilestoneCreated { + pub organization: ContractAddress, + pub project_id: u64, + pub milestone_id: u64, + pub milestone_description: felt252, + pub milestone_amount: u256, + pub created_at: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MilestoneCompleted { + pub project_id: u64, + pub milestone_id: u64, + } + + #[constructor] + fn constructor(ref self: ContractState, default_admin: ContractAddress) { + assert(default_admin != contract_address_const::<0>(), ERROR_ZERO_ADDRESS); + + // Initialize access control + self.accesscontrol.initializer(); + self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, default_admin); + self.accesscontrol._grant_role(ADMIN_ROLE, default_admin); + + // Initialize contract storage + self.admin.write(default_admin); + self.is_paused.write(false); + } + + #[abi(embed_v0)] + impl MilestoneManagerImpl of IMilestoneManager { + fn create_milestone( + ref self: ContractState, + organization: ContractAddress, + project_id: u64, + milestone_description: felt252, + milestone_amount: u256, + ) -> u64 { + // Ensure the contract is not paused + self.assert_not_paused(); + + // Verify caller's authorization + let caller = get_caller_address(); + let admin = self.admin.read(); + + assert( + caller == admin + || self.accesscontrol.has_role(ORGANIZATION_ROLE, caller) + || caller == organization, + ERROR_UNAUTHORIZED, + ); + + // Verify project exists with a valid ID + assert(project_id > 0, ERROR_INVALID_PROJECT_ID); + + // Generate new milestone ID + let milestone_id = self.project_milestone_count.read(project_id) + 1; + + // Create new milestone + let created_at = get_block_timestamp(); + let new_milestone = Milestone { + project_id, + milestone_id, + organization, + milestone_description, + milestone_amount, + created_at, + completed: false, + released: false, + }; + self.milestones.write((project_id, milestone_id), new_milestone); + self.project_milestone_count.write(project_id, milestone_id); + self + .emit( + Event::MilestoneCreated( + MilestoneCreated { + organization, + project_id, + milestone_id, + milestone_description, + milestone_amount, + created_at, + }, + ), + ); + milestone_id + } + + fn set_milestone_complete(ref self: ContractState, project_id: u64, milestone_id: u64) { + // Ensure the contract is not paused + self.assert_not_paused(); + let mut milestone = self.milestones.read((project_id, milestone_id)); + assert(milestone.project_id == project_id, ERROR_INVALID_MILESTONE); + assert(milestone.milestone_id == milestone_id, ERROR_INVALID_MILESTONE); + assert(!milestone.completed, ERROR_MILESTONE_ALREADY_COMPLETED); + milestone.completed = true; + self.milestones.write((project_id, milestone_id), milestone); + self.emit(Event::MilestoneCompleted(MilestoneCompleted { project_id, milestone_id })); + } + + fn get_milestone(self: @ContractState, project_id: u64, milestone_id: u64) -> Milestone { + self.milestones.read((project_id, milestone_id)) + } + + fn get_project_milestones(self: @ContractState, project_id: u64) -> Array { + let mut milestones = ArrayTrait::new(); + let milestone_count = self.project_milestone_count.read(project_id); + let mut i: u64 = 1; + while i != milestone_count + 1 { + milestones.append(self.milestones.read((project_id, i))); + i += 1; + } + milestones + } + + fn get_admin(self: @ContractState) -> ContractAddress { + self.admin.read() + } + + fn is_paused(self: @ContractState) -> bool { + self.is_paused.read() + } + + fn pause_contract(ref self: ContractState) { + let caller = get_caller_address(); + assert(caller == self.admin.read(), ERROR_ONLY_ADMIN); + assert(!self.is_paused.read(), ERROR_ALREADY_PAUSED); + self.is_paused.write(true); + } + + fn unpause_contract(ref self: ContractState) { + let caller = get_caller_address(); + assert(caller == self.admin.read(), ERROR_ONLY_ADMIN); + self.is_paused.write(false); + } + } + + #[generate_trait] + pub impl Internal of InternalTrait { + // Internal view function + // - Takes `@self` as it only needs to read state + // - Can only be called by other functions within the contract + fn assert_not_paused(self: @ContractState) { + assert(!self.is_paused.read(), ERROR_CONTRACT_PAUSED); + } + } +} diff --git a/onchain/milestone_manager/src/interfaces/IMilestoneManager.cairo b/onchain/milestone_manager/src/interfaces/IMilestoneManager.cairo new file mode 100644 index 0000000..72bba1c --- /dev/null +++ b/onchain/milestone_manager/src/interfaces/IMilestoneManager.cairo @@ -0,0 +1,26 @@ +use milestone_manager::base::types::Milestone; +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IMilestoneManager { + // Milestone Management + fn create_milestone( + ref self: TContractState, + organization: ContractAddress, + project_id: u64, + milestone_description: felt252, + milestone_amount: u256, + ) -> u64; + + fn set_milestone_complete(ref self: TContractState, project_id: u64, milestone_id: u64); + + fn get_milestone(self: @TContractState, project_id: u64, milestone_id: u64) -> Milestone; + + fn get_project_milestones(self: @TContractState, project_id: u64) -> Array; + + // Admin functions + fn get_admin(self: @TContractState) -> ContractAddress; + fn is_paused(self: @TContractState) -> bool; + fn pause_contract(ref self: TContractState); + fn unpause_contract(ref self: TContractState); +} diff --git a/onchain/milestone_manager/src/lib.cairo b/onchain/milestone_manager/src/lib.cairo new file mode 100644 index 0000000..21e9471 --- /dev/null +++ b/onchain/milestone_manager/src/lib.cairo @@ -0,0 +1,17 @@ +// Make modules public so they can be accessed +pub mod base { + pub mod errors; + pub mod types; +} + +pub mod interfaces { + pub mod IMilestoneManager; +} + +pub mod budgetchain { + pub mod MilestoneManager; +} + +// Re-export the main modules for easier access +pub use budgetchain::MilestoneManager; +pub use interfaces::IMilestoneManager; diff --git a/onchain/milestone_manager/tests/test_contract.cairo b/onchain/milestone_manager/tests/test_contract.cairo new file mode 100644 index 0000000..509a488 --- /dev/null +++ b/onchain/milestone_manager/tests/test_contract.cairo @@ -0,0 +1,312 @@ +use core::array::ArrayTrait; +use core::result::ResultTrait; +use core::traits::Into; +use milestone_manager::base::errors::*; +use milestone_manager::budgetchain::MilestoneManager::*; +use milestone_manager::interfaces::IMilestoneManager::{ + IMilestoneManagerDispatcher, IMilestoneManagerDispatcherTrait, +}; +use snforge_std::{ + CheatSpan, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, + cheat_caller_address, declare, spy_events, +}; +use starknet::{ContractAddress, contract_address_const}; + + +fn ADMIN() -> ContractAddress { + contract_address_const::<'ADMIN'>() +} + +fn ORGANIZATION() -> ContractAddress { + contract_address_const::<'ORGANIZATION'>() +} + +fn OTHER_ORG() -> ContractAddress { + contract_address_const::<'OTHER_ORG'>() +} + +fn NON_ORG() -> ContractAddress { + contract_address_const::<'NON_ORG'>() +} + +fn PROJECT_OWNER() -> ContractAddress { + contract_address_const::<'PROJECT_OWNER'>() +} + + +fn setup_test_data() -> (u64, u256, felt252) { + (1_u64, // project_id + 500_u256, // milestone_amount + 'Test milestone' // milestone_description + ) +} + + +fn deploy_milestone_manager( + admin: ContractAddress, +) -> (ContractAddress, IMilestoneManagerDispatcher) { + let contract_class = declare("MilestoneManager").unwrap().contract_class(); + + let mut calldata: Array = ArrayTrait::new(); + calldata.append(admin.into()); + + let (contract_address, _) = contract_class.deploy(@calldata).unwrap(); + + (contract_address, IMilestoneManagerDispatcher { contract_address }) +} + +#[test] +fn test_create_milestone() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + // Create milestone as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + + // Set up event spy + let mut spy = spy_events(); + + // Create milestone + let milestone_id = dispatcher + .create_milestone(org, project_id, milestone_description, milestone_amount); + + assert(milestone_id == 1, 'Incorrect milestone ID'); + + let milestone = dispatcher.get_milestone(project_id, milestone_id); + assert(milestone.project_id == project_id, 'Wrong project ID'); + assert(milestone.milestone_id == milestone_id, 'Wrong milestone ID'); + assert(milestone.organization == org, 'Wrong organization'); + assert(milestone.milestone_description == milestone_description, 'Wrong description'); + assert(milestone.milestone_amount == milestone_amount, 'Wrong amount'); + assert(!milestone.completed, 'Should not be completed'); + assert(!milestone.released, 'Should not be released'); + + // Verify event was emitted + spy + .assert_emitted( + @array![ + ( + contract_address, + MilestoneManager::Event::MilestoneCreated( + MilestoneManager::MilestoneCreated { + organization: org, + project_id, + milestone_id, + milestone_description, + milestone_amount, + created_at: milestone.created_at, + }, + ), + ), + ], + ); +} + +#[test] +fn test_create_multiple_milestones() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + // Create milestones as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(2)); + + let milestone_id1 = dispatcher + .create_milestone(org, project_id, milestone_description, milestone_amount); + + let milestone_id2 = dispatcher + .create_milestone(org, project_id, 'Second milestone', milestone_amount * 2); + + assert(milestone_id1 == 1, 'Incorrect first milestone ID'); + assert(milestone_id2 == 2, 'Incorrect second milestone ID'); + + let milestone1 = dispatcher.get_milestone(project_id, milestone_id1); + let milestone2 = dispatcher.get_milestone(project_id, milestone_id2); + + assert(milestone1.milestone_description == milestone_description, 'Wrong description 1'); + assert(milestone1.milestone_amount == milestone_amount, 'Wrong amount 1'); + + assert(milestone2.milestone_description == 'Second milestone', 'Wrong description 2'); + assert(milestone2.milestone_amount == milestone_amount * 2, 'Wrong amount 2'); + + let milestones = dispatcher.get_project_milestones(project_id); + assert(milestones.len() == 2, 'Wrong number of milestones'); +} + +#[test] +fn test_set_milestone_complete() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + // Create milestone as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + let milestone_id = dispatcher + .create_milestone(org, project_id, milestone_description, milestone_amount); + + // Set up event spy + let mut spy = spy_events(); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.set_milestone_complete(project_id, milestone_id); + + let milestone = dispatcher.get_milestone(project_id, milestone_id); + assert(milestone.completed, 'Milestone not marked complete'); + assert(!milestone.released, 'Released should still be false'); + + // Verify event was emitted + spy + .assert_emitted( + @array![ + ( + contract_address, + MilestoneManager::Event::MilestoneCompleted( + MilestoneManager::MilestoneCompleted { project_id, milestone_id }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: 'Milestone already completed')] +fn test_cannot_complete_milestone_twice() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(2)); + let milestone_id = dispatcher + .create_milestone(org, project_id, milestone_description, milestone_amount); + dispatcher.set_milestone_complete(project_id, milestone_id); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.set_milestone_complete(project_id, milestone_id); +} + +#[test] +#[should_panic(expected: 'Invalid milestone')] +fn test_cannot_complete_nonexistent_milestone() { + let admin = ADMIN(); + let _org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, _, _) = setup_test_data(); + let nonexistent_milestone_id = 999_u64; + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.set_milestone_complete(project_id, nonexistent_milestone_id); +} + +#[test] +#[should_panic(expected: 'Caller not authorized')] +fn test_unauthorized_cannot_create_milestone() { + let admin = ADMIN(); + let org = ORGANIZATION(); + let non_org = NON_ORG(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + cheat_caller_address(contract_address, non_org, CheatSpan::TargetCalls(1)); + dispatcher.create_milestone(org, project_id, milestone_description, milestone_amount); +} + +#[test] +fn test_pause_and_unpause_contract() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + assert(!dispatcher.is_paused(), 'Contract should not be paused'); + + // Pause contract as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.pause_contract(); + + assert(dispatcher.is_paused(), 'Contract should be paused'); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.unpause_contract(); + + assert(!dispatcher.is_paused(), 'Contract should be unpaused'); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + let milestone_id = dispatcher + .create_milestone(org, project_id, milestone_description, milestone_amount); + + // Verify milestone was created + assert(milestone_id == 1, 'Milestone should be created'); +} + +#[test] +#[should_panic(expected: 'Contract is paused')] +fn test_cannot_create_milestone_when_paused() { + let admin = ADMIN(); + let org = ORGANIZATION(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Setup test data + let (project_id, milestone_amount, milestone_description) = setup_test_data(); + + // Pause contract as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.pause_contract(); + + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.create_milestone(org, project_id, milestone_description, milestone_amount); +} + +#[test] +#[should_panic(expected: 'ONLY ADMIN')] +fn test_only_admin_can_pause_contract() { + let admin = ADMIN(); + let _non_admin = NON_ORG(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + cheat_caller_address(contract_address, _non_admin, CheatSpan::TargetCalls(1)); + dispatcher.pause_contract(); +} + +#[test] +#[should_panic(expected: 'ONLY ADMIN')] +fn test_only_admin_can_unpause_contract() { + let admin = ADMIN(); + let non_admin = NON_ORG(); + + let (contract_address, dispatcher) = deploy_milestone_manager(admin); + + // Pause contract as admin + cheat_caller_address(contract_address, admin, CheatSpan::TargetCalls(1)); + dispatcher.pause_contract(); + + cheat_caller_address(contract_address, non_admin, CheatSpan::TargetCalls(1)); + dispatcher.unpause_contract(); +}