diff --git a/.gitignore b/.gitignore index b595be6..d04f25e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +target +.snfoundry_cache/ +snfoundry_trace/ +coverage/ +profile/ diff --git a/onchain/budget_contract/Scarb.lock b/onchain/budget_contract/Scarb.lock new file mode 100644 index 0000000..4036d1a --- /dev/null +++ b/onchain/budget_contract/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "fund_request_contract" +version = "0.1.0" +dependencies = [ + "snforge_std", +] + +[[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/onchain/budget_contract/Scarb.toml b/onchain/budget_contract/Scarb.toml new file mode 100644 index 0000000..3a61494 --- /dev/null +++ b/onchain/budget_contract/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "fund_request_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.11.4" + +[dev-dependencies] +snforge_std = "0.44.0" +assert_macros = "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/budget_contract/scripts/deploy.sh b/onchain/budget_contract/scripts/deploy.sh new file mode 100644 index 0000000..e69de29 diff --git a/onchain/budget_contract/snfoundry.toml b/onchain/budget_contract/snfoundry.toml new file mode 100644 index 0000000..0f29e90 --- /dev/null +++ b/onchain/budget_contract/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://starknet-sepolia.public.blastapi.io/rpc/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/budget_contract/src/fund_request.cairo b/onchain/budget_contract/src/fund_request.cairo new file mode 100644 index 0000000..435e5f4 --- /dev/null +++ b/onchain/budget_contract/src/fund_request.cairo @@ -0,0 +1,308 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IFundRequest { + fn create_fund_request( + ref self: TContractState, + project_id: u64, + milestone_id: u64, + amount: u128 + ) -> u64; + + fn approve_fund_request(ref self: TContractState, request_id: u64); + fn reject_fund_request(ref self: TContractState, request_id: u64); + fn get_fund_request(self: @TContractState, request_id: u64) -> FundRequestStruct; + fn get_request_count(self: @TContractState) -> u64; + fn is_authorized_approver(self: @TContractState, address: ContractAddress) -> bool; + fn add_authorized_approver(ref self: TContractState, approver: ContractAddress); + fn remove_authorized_approver(ref self: TContractState, approver: ContractAddress); + fn get_owner(self: @TContractState) -> ContractAddress; + fn get_project_contract(self: @TContractState) -> ContractAddress; +} + +#[starknet::interface] +pub trait IProjectContract { + fn is_milestone_completed(self: @TContractState, project_id: u64, milestone_id: u64) -> bool; + fn get_project_owner(self: @TContractState, project_id: u64) -> ContractAddress; + fn update_project_budget(ref self: TContractState, project_id: u64, amount: u128); +} + +#[derive(Drop, starknet::Event)] +pub struct FundsRequested { + pub project_id: u64, + pub request_id: u64, + pub milestone_id: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct FundsReleased { + pub project_id: u64, + pub request_id: u64, + pub milestone_id: u64, + pub amount: u128, +} + +#[derive(Drop, starknet::Event)] +pub struct FundsReturned { + pub project_id: u64, + pub amount: u128, + pub project_owner: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct AuthorizedApproverAdded { + pub approver: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct AuthorizedApproverRemoved { + pub approver: ContractAddress, +} + +#[derive(Drop, Serde, starknet::Store, Copy)] +#[allow(starknet::store_no_default_variant)] +pub enum FundRequestStatus { + Pending, + Approved, + Rejected, +} + +#[derive(Drop, Serde, starknet::Store, Copy)] +pub struct FundRequestStruct { + pub project_id: u64, + pub milestone_id: u64, + pub amount: u128, + pub requester: ContractAddress, + pub status: FundRequestStatus, +} + +#[starknet::contract] +pub mod FundRequest { + use super::{ + IFundRequest, IProjectContractDispatcher, IProjectContractDispatcherTrait, + FundsRequested, FundsReleased, FundsReturned, AuthorizedApproverAdded, AuthorizedApproverRemoved, + FundRequestStatus, FundRequestStruct + }; + use starknet::{ContractAddress, get_caller_address}; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, StoragePointerWriteAccess}; + use core::num::traits::Zero; + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + FundsRequested: FundsRequested, + FundsReleased: FundsReleased, + FundsReturned: FundsReturned, + AuthorizedApproverAdded: AuthorizedApproverAdded, + AuthorizedApproverRemoved: AuthorizedApproverRemoved, + } + + #[storage] + struct Storage { + fund_requests: Map, + request_counter: u64, + owner: ContractAddress, + project_contract: ContractAddress, + authorized_approvers: Map, + request_exists: Map, // Track which requests exist + } + + pub mod Errors { + pub const UNAUTHORIZED: felt252 = 'Unauthorized access'; + pub const MILESTONE_NOT_COMPLETED: felt252 = 'Milestone not completed'; + pub const INVALID_AMOUNT: felt252 = 'Invalid amount'; + pub const REQUEST_NOT_FOUND: felt252 = 'Request not found'; + pub const REQUEST_NOT_PENDING: felt252 = 'Request not pending'; + pub const NOT_PROJECT_OWNER: felt252 = 'Not project owner'; + pub const ZERO_ADDRESS: felt252 = 'Zero address not allowed'; + pub const ALREADY_AUTHORIZED: felt252 = 'Already authorized'; + pub const NOT_AUTHORIZED: felt252 = 'Not authorized approver'; + } + + #[constructor] + fn constructor( + ref self: ContractState, + owner: ContractAddress, + project_contract: ContractAddress + ) { + assert(!owner.is_zero(), Errors::ZERO_ADDRESS); + assert(!project_contract.is_zero(), Errors::ZERO_ADDRESS); + + self.owner.write(owner); + self.project_contract.write(project_contract); + self.request_counter.write(0); + + // Owner is automatically an authorized approver + self.authorized_approvers.write(owner, true); + } + + #[abi(embed_v0)] + impl FundRequestImpl of IFundRequest { + fn create_fund_request( + ref self: ContractState, + project_id: u64, + milestone_id: u64, + amount: u128 + ) -> u64 { + let caller = get_caller_address(); + + // Validate input + assert(amount > 0, Errors::INVALID_AMOUNT); + + // Get project contract dispatcher + let project_dispatcher = IProjectContractDispatcher { + contract_address: self.project_contract.read() + }; + + // Verify caller is the project owner + let project_owner = project_dispatcher.get_project_owner(project_id); + assert(caller == project_owner, Errors::NOT_PROJECT_OWNER); + + // Verify milestone is completed + assert( + project_dispatcher.is_milestone_completed(project_id, milestone_id), + Errors::MILESTONE_NOT_COMPLETED + ); + + // Create new request + let request_id = self.request_counter.read() + 1; + self.request_counter.write(request_id); + + let fund_request = FundRequestStruct { + project_id, + milestone_id, + amount, + requester: caller, + status: FundRequestStatus::Pending, + }; + + self.fund_requests.write(request_id, fund_request); + self.request_exists.write(request_id, true); // Mark as existing + + + self.emit(FundsRequested { project_id, request_id, milestone_id }); + + request_id + } + + fn approve_fund_request(ref self: ContractState, request_id: u64) { + let caller = get_caller_address(); + + + assert(self.authorized_approvers.read(caller), Errors::UNAUTHORIZED); + + + assert(self.request_exists.read(request_id), Errors::REQUEST_NOT_FOUND); + + + let request = self.fund_requests.read(request_id); + + match request.status { + FundRequestStatus::Pending => {}, + _ => core::panic_with_felt252(Errors::REQUEST_NOT_PENDING) + } + + + let updated_request = FundRequestStruct { + project_id: request.project_id, + milestone_id: request.milestone_id, + amount: request.amount, + requester: request.requester, + status: FundRequestStatus::Approved, + }; + + + self.fund_requests.write(request_id, updated_request); + + + let project_dispatcher = IProjectContractDispatcher { + contract_address: self.project_contract.read() + }; + project_dispatcher.update_project_budget(request.project_id, request.amount); + + + self.emit(FundsReleased { + project_id: request.project_id, + request_id, + milestone_id: request.milestone_id, + amount: request.amount, + }); + } + + fn reject_fund_request(ref self: ContractState, request_id: u64) { + let caller = get_caller_address(); + + + assert(self.authorized_approvers.read(caller), Errors::UNAUTHORIZED); + + + assert(self.request_exists.read(request_id), Errors::REQUEST_NOT_FOUND); + + + let request = self.fund_requests.read(request_id); + + match request.status { + FundRequestStatus::Pending => {}, + _ => core::panic_with_felt252(Errors::REQUEST_NOT_PENDING) + } + + + let updated_request = FundRequestStruct { + project_id: request.project_id, + milestone_id: request.milestone_id, + amount: request.amount, + requester: request.requester, + status: FundRequestStatus::Rejected, + }; + + self.fund_requests.write(request_id, updated_request); + } + + fn get_fund_request(self: @ContractState, request_id: u64) -> FundRequestStruct { + + assert(self.request_exists.read(request_id), Errors::REQUEST_NOT_FOUND); + self.fund_requests.read(request_id) + } + + fn get_request_count(self: @ContractState) -> u64 { + self.request_counter.read() + } + + fn is_authorized_approver(self: @ContractState, address: ContractAddress) -> bool { + self.authorized_approvers.read(address) + } + + fn add_authorized_approver(ref self: ContractState, approver: ContractAddress) { + self._only_owner(); + assert(!approver.is_zero(), Errors::ZERO_ADDRESS); + assert(!self.authorized_approvers.read(approver), Errors::ALREADY_AUTHORIZED); + + self.authorized_approvers.write(approver, true); + self.emit(AuthorizedApproverAdded { approver }); + } + + fn remove_authorized_approver(ref self: ContractState, approver: ContractAddress) { + self._only_owner(); + assert(self.authorized_approvers.read(approver), Errors::NOT_AUTHORIZED); + + self.authorized_approvers.write(approver, false); + self.emit(AuthorizedApproverRemoved { approver }); + } + + fn get_owner(self: @ContractState) -> ContractAddress { + self.owner.read() + } + + fn get_project_contract(self: @ContractState) -> ContractAddress { + self.project_contract.read() + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _only_owner(self: @ContractState) { + let caller = get_caller_address(); + assert(caller == self.owner.read(), Errors::UNAUTHORIZED); + } + } +} \ No newline at end of file diff --git a/onchain/budget_contract/src/lib.cairo b/onchain/budget_contract/src/lib.cairo new file mode 100644 index 0000000..853fdaa --- /dev/null +++ b/onchain/budget_contract/src/lib.cairo @@ -0,0 +1,10 @@ +pub mod fund_request; + + +pub use fund_request::{ + IFundRequest, IFundRequestDispatcher, IFundRequestDispatcherTrait, + IProjectContract, IProjectContractDispatcher, IProjectContractDispatcherTrait, + FundRequestStatus, FundRequestStruct, + FundsRequested, FundsReleased, FundsReturned, + AuthorizedApproverAdded, AuthorizedApproverRemoved +}; \ No newline at end of file diff --git a/onchain/budget_contract/tests/test_fund_request.cairo b/onchain/budget_contract/tests/test_fund_request.cairo new file mode 100644 index 0000000..b1809ca --- /dev/null +++ b/onchain/budget_contract/tests/test_fund_request.cairo @@ -0,0 +1,239 @@ +use starknet::ContractAddress; +use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address}; +use fund_request_contract::{ + IFundRequestDispatcher, IFundRequestDispatcherTrait +}; + + +#[starknet::interface] +trait IProjectContract { + fn is_milestone_completed(self: @TContractState, project_id: u64, milestone_id: u64) -> bool; + fn get_project_owner(self: @TContractState, project_id: u64) -> ContractAddress; + fn update_project_budget(ref self: TContractState, project_id: u64, amount: u128); +} + +#[starknet::contract] +mod MockProjectContract { + use super::IProjectContract; + use starknet::ContractAddress; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + + #[storage] + struct Storage { + completed_milestones: Map<(u64, u64), bool>, + project_owners: Map, + project_budgets: Map, + } + + #[abi(embed_v0)] + impl MockProjectContractImpl of IProjectContract { + fn is_milestone_completed(self: @ContractState, project_id: u64, milestone_id: u64) -> bool { + self.completed_milestones.read((project_id, milestone_id)) + } + + fn get_project_owner(self: @ContractState, project_id: u64) -> ContractAddress { + self.project_owners.read(project_id) + } + + fn update_project_budget(ref self: ContractState, project_id: u64, amount: u128) { + let current_budget = self.project_budgets.read(project_id); + self.project_budgets.write(project_id, current_budget + amount); + } + } + + #[generate_trait] + impl TestHelpersImpl of TestHelpersTrait { + fn set_milestone_completed(ref self: ContractState, project_id: u64, milestone_id: u64, completed: bool) { + self.completed_milestones.write((project_id, milestone_id), completed); + } + + fn set_project_owner(ref self: ContractState, project_id: u64, owner: ContractAddress) { + self.project_owners.write(project_id, owner); + } + + fn get_project_budget(self: @ContractState, project_id: u64) -> u128 { + self.project_budgets.read(project_id) + } + } +} + + +fn get_contract_address(address: felt252) -> ContractAddress { + address.try_into().unwrap() +} + + +fn get_zero_address() -> ContractAddress { + 0_felt252.try_into().unwrap() +} + +fn deploy_mock_project_contract() -> ContractAddress { + let contract = declare("MockProjectContract").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap(); + contract_address +} + +fn deploy_fund_request_contract( + owner: ContractAddress, + project_contract: ContractAddress +) -> IFundRequestDispatcher { + let mut calldata = ArrayTrait::new(); + calldata.append(owner.into()); + calldata.append(project_contract.into()); + + let contract = declare("FundRequest").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + + IFundRequestDispatcher { contract_address } +} + + +fn setup() -> (IFundRequestDispatcher, ContractAddress, ContractAddress, ContractAddress, ContractAddress) { + let owner = get_contract_address('owner'); + let project_owner = get_contract_address('project_owner'); + let approver = get_contract_address('approver'); + + let project_contract_address = deploy_mock_project_contract(); + let fund_request = deploy_fund_request_contract(owner, project_contract_address); + + (fund_request, owner, project_owner, approver, project_contract_address) +} + +#[test] +fn test_constructor() { + let (fund_request, owner, _project_owner, _approver, _project_contract_address) = setup(); + + assert(fund_request.get_request_count() == 0, 'Initial count should be 0'); + assert(fund_request.is_authorized_approver(owner), 'Owner should be authorized'); +} + +#[test] +#[should_panic(expected: ('Result::unwrap failed.',))] +fn test_constructor_zero_owner() { + let project_contract = deploy_mock_project_contract(); + let _fund_request = deploy_fund_request_contract(get_zero_address(), project_contract); +} + +#[test] +#[should_panic(expected: ('Result::unwrap failed.',))] +fn test_constructor_zero_project_contract() { + let owner = get_contract_address('owner'); + let _fund_request = deploy_fund_request_contract(owner, get_zero_address()); +} + +#[test] +fn test_basic_functionality() { + let (fund_request, owner, _project_owner, _approver, _project_contract_address) = setup(); + let count = fund_request.get_request_count(); + let is_authorized = fund_request.is_authorized_approver(owner); + + assert(count == 0, 'Count should be 0'); + assert(is_authorized, 'Owner should be authorized'); +} + +#[test] +#[should_panic(expected: ('Invalid amount',))] +fn test_create_fund_request_zero_amount() { + let (fund_request, _owner, project_owner, _approver, _project_contract_address) = setup(); + + start_cheat_caller_address(fund_request.contract_address, project_owner); + fund_request.create_fund_request(1, 1, 0); + stop_cheat_caller_address(fund_request.contract_address); +} + +#[test] +fn test_get_request_count() { + let (fund_request, _owner, _project_owner, _approver, _project_contract_address) = setup(); + + let count = fund_request.get_request_count(); + assert(count == 0, 'Initial count should be 0'); +} + +#[test] +fn test_is_authorized_approver() { + let (fund_request, owner, _project_owner, approver, _project_contract_address) = setup(); + + assert(fund_request.is_authorized_approver(owner), 'Owner should be authorized'); + + + assert(!fund_request.is_authorized_approver(approver), 'Should not be authorized'); +} + +#[test] +#[should_panic(expected: ('Request not found',))] +fn test_get_nonexistent_request() { + let (fund_request, _owner, _project_owner, _approver, _project_contract_address) = setup(); + + fund_request.get_fund_request(999); // Non-existent request +} + +#[test] +#[should_panic(expected: ('Unauthorized access',))] +fn test_approve_fund_request_unauthorized() { + let (fund_request, _owner, _project_owner, _approver, _project_contract_address) = setup(); + + let unauthorized = get_contract_address('unauthorized'); + start_cheat_caller_address(fund_request.contract_address, unauthorized); + fund_request.approve_fund_request(1); + stop_cheat_caller_address(fund_request.contract_address); +} + +#[test] +#[should_panic(expected: ('Unauthorized access',))] +fn test_reject_fund_request_unauthorized() { + let (fund_request, _owner, _project_owner, _approver, _project_contract_address) = setup(); + + let unauthorized = get_contract_address('unauthorized'); + start_cheat_caller_address(fund_request.contract_address, unauthorized); + fund_request.reject_fund_request(1); + stop_cheat_caller_address(fund_request.contract_address); +} + +#[test] +fn test_contract_deployment() { + let owner = get_contract_address('owner'); + let project_contract = deploy_mock_project_contract(); + let fund_request = deploy_fund_request_contract(owner, project_contract); + + assert(fund_request.contract_address != get_zero_address(), 'Contract should be deployed'); +} + +#[test] +fn test_owner_functions() { + let (fund_request, owner, _project_owner, _approver, _project_contract_address) = setup(); + + + let contract_owner = fund_request.get_owner(); + assert(contract_owner == owner, 'Owner should match'); + + + let project_contract = fund_request.get_project_contract(); + assert(project_contract == _project_contract_address, 'Project contract match'); +} + +#[test] +fn test_add_authorized_approver() { + let (fund_request, owner, _project_owner, approver, _project_contract_address) = setup(); + + + assert(!fund_request.is_authorized_approver(approver), 'Should not be authorized'); + + + start_cheat_caller_address(fund_request.contract_address, owner); + fund_request.add_authorized_approver(approver); + stop_cheat_caller_address(fund_request.contract_address); + + + assert(fund_request.is_authorized_approver(approver), 'Should be authorized'); +} + +#[test] +#[should_panic(expected: ('Unauthorized access',))] +fn test_add_authorized_approver_unauthorized() { + let (fund_request, _owner, _project_owner, approver, _project_contract_address) = setup(); + + let unauthorized = get_contract_address('unauthorized'); + start_cheat_caller_address(fund_request.contract_address, unauthorized); + fund_request.add_authorized_approver(approver); + stop_cheat_caller_address(fund_request.contract_address); +} \ No newline at end of file diff --git a/src/app/dashboard/components/navBar.tsx b/src/app/dashboard/components/navBar.tsx index b695336..2234991 100644 --- a/src/app/dashboard/components/navBar.tsx +++ b/src/app/dashboard/components/navBar.tsx @@ -4,28 +4,26 @@ import { Search } from 'lucide-react'; const NavBar = () => { return ( - -