diff --git a/onchain/budgetchain_contracts/src/base/errors.cairo b/onchain/budgetchain_contracts/src/base/errors.cairo index 2be02ab..a4578db 100644 --- a/onchain/budgetchain_contracts/src/base/errors.cairo +++ b/onchain/budgetchain_contracts/src/base/errors.cairo @@ -1,3 +1,11 @@ // contract custom errors pub const ERROR_ONLY_ADMIN: felt252 = 'ONLY ADMIN'; pub const ERROR_ZERO_ADDRESS: felt252 = 'Zero address forbidden'; + +// Transaction creation errors +pub const AMOUNT_CANNOT_BE_ZERO: felt252 = 'Amount must be greater than 0'; +pub const RECIPIENT_CANNOT_BE_ZERO: felt252 = 'Recipient cannot be zero addr'; + +// Transaction query errors +pub const TRANSACTION_NOT_FOUND: felt252 = 'Transaction not found'; +pub const INVALID_PAGINATION: felt252 = 'Invalid pagination parameters'; diff --git a/onchain/budgetchain_contracts/src/base/types.cairo b/onchain/budgetchain_contracts/src/base/types.cairo index 95e30fb..f48a01e 100644 --- a/onchain/budgetchain_contracts/src/base/types.cairo +++ b/onchain/budgetchain_contracts/src/base/types.cairo @@ -10,6 +10,33 @@ pub struct Organization { pub created_at: u64, } +#[derive(Drop, starknet::Store, Serde)] +pub struct Transaction { + pub id: u64, + pub project_id: u64, + pub sender: ContractAddress, + pub recipient: ContractAddress, + pub amount: u128, + pub timestamp: u64, + pub category: felt252, + pub description: felt252, +} + +#[derive(Drop, starknet::Event)] +pub struct TransactionCreated { + #[key] + pub id: u256, + #[key] + pub project_id: u64, + pub sender: ContractAddress, + pub recipient: ContractAddress, + pub amount: u128, + pub timestamp: u64, + pub category: felt252, + pub description: felt252, +} + + // ROLE CONSTANTS pub const ADMIN_ROLE: felt252 = selector!("ADMIN_ROLE"); pub const ORGANIZATION_ROLE: felt252 = selector!("ORGANIZATION_ROLE"); diff --git a/onchain/budgetchain_contracts/src/budgetchain/Budget.cairo b/onchain/budgetchain_contracts/src/budgetchain/Budget.cairo index 3b4d4af..8af2c35 100644 --- a/onchain/budgetchain_contracts/src/budgetchain/Budget.cairo +++ b/onchain/budgetchain_contracts/src/budgetchain/Budget.cairo @@ -1,16 +1,16 @@ #[feature("deprecated_legacy_map")] #[starknet::contract] pub mod Budget { - use budgetchain_contracts::base::errors::*; - use budgetchain_contracts::base::types::{ADMIN_ROLE,ORGANIZATION_ROLE, Organization}; + use budgetchain_contracts::base::errors::*; + use budgetchain_contracts::base::types::{ADMIN_ROLE, ORGANIZATION_ROLE, Organization}; use budgetchain_contracts::interfaces::IBudget::IBudget; use core::array::{Array, ArrayTrait}; use core::option::Option; use openzeppelin::access::accesscontrol::{AccessControlComponent, DEFAULT_ADMIN_ROLE}; use openzeppelin::introspection::src5::SRC5Component; use starknet::storage::{ - Map, StorageMapReadAccess, StorageMapWriteAccess, - StoragePointerReadAccess, StoragePointerWriteAccess + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, }; use starknet::{ ContractAddress, contract_address_const, get_block_timestamp, get_caller_address, @@ -18,28 +18,24 @@ pub mod Budget { 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, // Admin address - // Transaction storage - owner: ContractAddress, // Owner of the contract - org_count: u256, // Unique organization ID counter - organizations: Map, // Map of organizations by ID - org_addresses: Map< - ContractAddress, bool, - >, // Map of organization addresses to their active status - org_list: Array, // List of all organizations + admin: ContractAddress, + owner: ContractAddress, + org_count: u256, + organizations: Map, + org_addresses: Map, + org_list: Array, #[substorage(v0)] accesscontrol: AccessControlComponent::Storage, #[substorage(v0)] @@ -79,13 +75,9 @@ pub mod Budget { #[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); } @@ -94,15 +86,11 @@ pub mod Budget { fn create_organization( ref self: ContractState, name: felt252, org_address: ContractAddress, mission: felt252, ) -> u256 { - // Ensure only the admin can add an organization let admin = self.admin.read(); assert(admin == get_caller_address(), ERROR_ONLY_ADMIN); let created_at = get_block_timestamp(); - // // Generate a unique organization ID let org_id: u256 = self.org_count.read(); - - // Create and store the organization let organization = Organization { id: org_id, address: org_address, @@ -111,40 +99,26 @@ pub mod Budget { mission, created_at: created_at, }; - - // Emit an event self.emit(OrganizationAdded { id: org_id, address: org_address, name: name }); self.org_count.write(org_id + 1); self.organizations.write(org_id, organization); self.org_addresses.write(org_address, true); - - // Grant organization role self.accesscontrol._grant_role(ORGANIZATION_ROLE, organization.address); - - // Emit an event self.emit(OrganizationAdded { id: org_id, address: org_address, name: name }); org_id } fn update_organization( - ref self: ContractState, - name: felt252, - org_id: u256, // org_address: ContractAddress, - mission: felt252, + ref self: ContractState, name: felt252, org_id: u256, mission: felt252, ) { - // Ensure only the admin can add an organization let admin = self.admin.read(); assert(admin == get_caller_address(), ERROR_ONLY_ADMIN); let mut org = self.organizations.read(org_id); - // org.is_active = false; org.name = name; - // org.address = org_address; org.mission = mission; - // org.created_at = get_block_timestamp(); - // org.is_active = true; self.organizations.write(org_id, org); } diff --git a/onchain/budgetchain_contracts/src/budgetchain/Ledger.cairo b/onchain/budgetchain_contracts/src/budgetchain/Ledger.cairo new file mode 100644 index 0000000..4a1742f --- /dev/null +++ b/onchain/budgetchain_contracts/src/budgetchain/Ledger.cairo @@ -0,0 +1,162 @@ +/// Ledger smart contract implementation +#[starknet::contract] +pub mod Ledger { + use core::num::traits::Zero; + use starknet::{ContractAddress, get_caller_address, get_block_timestamp}; + use crate::base::errors::{AMOUNT_CANNOT_BE_ZERO, RECIPIENT_CANNOT_BE_ZERO}; + use crate::base::types::{Transaction, TransactionCreated}; + use crate::interfaces::ILedger::ILedger; + use core::starknet::storage::{ + StoragePointerReadAccess, StoragePointerWriteAccess, Map, Vec, VecTrait, MutableVecTrait, + StorageMapReadAccess, StorageMapWriteAccess, + }; + use core::array::ArrayTrait; + use core::array::Array; + + #[storage] + struct Storage { + transactions: Map, + transaction_count: u64, + all_transaction_ids: Vec, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + TransactionCreated: TransactionCreated, + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.transaction_count.write(0); + } + + #[abi(embed_v0)] + impl LedgerImpl of ILedger { + fn create_transaction( + ref self: ContractState, + project_id: u64, + recipient: ContractAddress, + amount: u128, + category: felt252, + description: felt252, + ) -> u64 { + assert(amount > 0, AMOUNT_CANNOT_BE_ZERO); + // Check if recipient is zero address + let zero_address: ContractAddress = Zero::zero(); + assert(recipient != zero_address, RECIPIENT_CANNOT_BE_ZERO); + + // Get current transaction count and increment + let current_count = self.transaction_count.read(); + let mut transaction_id = current_count + 1; + self.transaction_count.write(transaction_id); + + // Get sender and timestamp + let sender = get_caller_address(); + let timestamp = get_block_timestamp(); + + // Create transaction + let transaction = Transaction { + id: transaction_id, + project_id, + sender, + recipient, + amount, + timestamp, + category, + description, + }; + + // Store transaction + self.transactions.write(transaction_id, transaction); + + // Add to all transactions list + self.all_transaction_ids.append().write(transaction_id); + + // Emit event + self + .emit( + TransactionCreated { + id: transaction_id.into(), + project_id, + sender, + recipient, + amount, + timestamp, + category, + description, + }, + ); + + transaction_id + } + + fn get_transaction_history( + self: @ContractState, project_id: Option, offset: u64, limit: u64, + ) -> Array { + let mut transactions = ArrayTrait::new(); + let total_count = self.all_transaction_ids.len(); + + match project_id { + Option::Some(pid) => { + // Filter transactions by project_id + let mut found_count = 0_u64; + let mut added_count = 0_u64; + + let mut i = 0_u64; + while i < total_count && added_count < limit { + let tx_id = self.all_transaction_ids.at(i).read(); + let transaction = self.transactions.read(tx_id); + + if transaction.project_id == pid { + if found_count >= offset { + transactions.append(transaction); + added_count += 1; + } + found_count += 1; + } + i += 1; + }; + }, + Option::None => { + // Get all transactions with pagination + let start_idx = offset; + let end_idx = core::cmp::min(start_idx + limit, total_count); + + let mut i = start_idx; + while i < end_idx { + let tx_id = self.all_transaction_ids.at(i).read(); + let transaction = self.transactions.read(tx_id); + transactions.append(transaction); + i += 1; + }; + }, + } + + transactions + } + + fn get_transaction(self: @ContractState, transaction_id: u64) -> Option { + if transaction_id == 0 || transaction_id > self.transaction_count.read() { + Option::None + } else { + let transaction = self.transactions.read(transaction_id); + if transaction.id == 0 { + Option::None + } else { + Option::Some(transaction) + } + } + } + + fn get_transaction_count(self: @ContractState) -> u64 { + self.transaction_count.read() + } + + fn get_project_transactions( + self: @ContractState, project_id: u64, offset: u64, limit: u64, + ) -> Array { + self.get_transaction_history(Option::Some(project_id), offset, limit) + } + } +} diff --git a/onchain/budgetchain_contracts/src/interfaces/ILedger.cairo b/onchain/budgetchain_contracts/src/interfaces/ILedger.cairo new file mode 100644 index 0000000..ad5ab6e --- /dev/null +++ b/onchain/budgetchain_contracts/src/interfaces/ILedger.cairo @@ -0,0 +1,22 @@ +use starknet::ContractAddress; +use crate::base::types::Transaction; + +#[starknet::interface] +pub trait ILedger { + fn create_transaction( + ref self: TContractState, + project_id: u64, + recipient: ContractAddress, + amount: u128, + category: felt252, + description: felt252, + ) -> u64; + fn get_transaction_history( + self: @TContractState, project_id: Option, offset: u64, limit: u64, + ) -> Array; + fn get_transaction(self: @TContractState, transaction_id: u64) -> Option; + fn get_transaction_count(self: @TContractState) -> u64; + fn get_project_transactions( + self: @TContractState, project_id: u64, offset: u64, limit: u64, + ) -> Array; +} diff --git a/onchain/budgetchain_contracts/src/lib.cairo b/onchain/budgetchain_contracts/src/lib.cairo index b035bc4..3013378 100644 --- a/onchain/budgetchain_contracts/src/lib.cairo +++ b/onchain/budgetchain_contracts/src/lib.cairo @@ -6,12 +6,15 @@ pub mod base { pub mod interfaces { pub mod IBudget; + pub mod ILedger; } pub mod budgetchain { pub mod Budget; + pub mod Ledger; } // Re-export the main modules for easier access -pub use budgetchain::Budget; -pub use interfaces::IBudget; +pub use budgetchain::{Budget, Ledger}; +pub use interfaces::{IBudget, ILedger}; +pub use base::types::Transaction; diff --git a/onchain/budgetchain_contracts/tests/test_ledger.cairo b/onchain/budgetchain_contracts/tests/test_ledger.cairo new file mode 100644 index 0000000..f9afc44 --- /dev/null +++ b/onchain/budgetchain_contracts/tests/test_ledger.cairo @@ -0,0 +1,291 @@ +use starknet::{ContractAddress, contract_address_const}; +use snforge_std::{ + declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, + stop_cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, spy_events, + EventSpyAssertionsTrait, +}; +use core::array::ArrayTrait; + +use budgetchain_contracts::base::types::{Transaction, TransactionCreated}; +use budgetchain_contracts::interfaces::ILedger::{ILedgerDispatcher, ILedgerDispatcherTrait}; +use budgetchain_contracts::budgetchain::Ledger; + +fn deploy_ledger_contract() -> ContractAddress { + let contract = declare("Ledger").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap(); + contract_address +} + +#[test] +fn test_create_transaction() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + start_cheat_block_timestamp(contract_address, 1000); + + let tx_id = dispatcher + .create_transaction( + 1, // project_id + recipient, + 500, // amount + 'fund_release', // category + 'Initial project funding' // description + ); + + assert(tx_id == 1, 'Transaction ID should be 1'); + + let transaction = dispatcher.get_transaction(tx_id).unwrap(); + assert(transaction.id == 1, 'Wrong transaction ID'); + assert(transaction.project_id == 1, 'Wrong project ID'); + assert(transaction.sender == sender, 'Wrong sender'); + assert(transaction.recipient == recipient, 'Wrong recipient'); + assert(transaction.amount == 500, 'Wrong amount'); + assert(transaction.timestamp == 1000, 'Wrong timestamp'); + assert(transaction.category == 'fund_release', 'Wrong category'); + assert(transaction.description == 'Initial project funding', 'Wrong description'); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_transaction_event_emission() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + start_cheat_block_timestamp(contract_address, 1500); + + let mut spy = spy_events(); + + let tx_id = dispatcher + .create_transaction( + 2, // project_id + recipient, + 750, // amount + 'payment', // category + 'Service payment' // description + ); + + // Check that the TransactionCreated event was emitted + spy + .assert_emitted( + @array![ + ( + contract_address, + Ledger::Ledger::Event::TransactionCreated( + TransactionCreated { + id: tx_id.into(), + project_id: 2, + sender: sender, + recipient: recipient, + amount: 750, + timestamp: 1500, + category: 'payment', + description: 'Service payment', + }, + ), + ), + ], + ); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_multiple_transactions() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient1 = contract_address_const::<'recipient1'>(); + let recipient2 = contract_address_const::<'recipient2'>(); + + start_cheat_caller_address(contract_address, sender); + start_cheat_block_timestamp(contract_address, 1000); + + // Create first transaction + let tx_id1 = dispatcher.create_transaction(1, recipient1, 500, 'fund_release', 'First funding'); + + // Create second transaction + let tx_id2 = dispatcher.create_transaction(1, recipient2, 300, 'payment', 'Service payment'); + + // Create third transaction for different project + let tx_id3 = dispatcher + .create_transaction(2, recipient1, 200, 'fund_release', 'Second project funding'); + + assert(tx_id1 == 1, 'First txn ID should be 1'); + assert(tx_id2 == 2, 'Second txn ID should be 2'); + assert(tx_id3 == 3, 'Third txn ID should be 3'); + + let count = dispatcher.get_transaction_count(); + assert(count == 3, 'Total txn count should be 3'); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_get_transaction_history_pagination() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + start_cheat_block_timestamp(contract_address, 1000); + + // Create 5 transactions + let mut i: u64 = 0; + while i < 5 { + dispatcher + .create_transaction( + 1, recipient, 100 + i.try_into().unwrap(), 'test', 'Test transaction', + ); + i += 1; + }; + + // Test pagination - get first 2 transactions + let page1 = dispatcher.get_transaction_history(Option::None, 0, 2); + assert(page1.len() == 2, 'Page 1 should have 2 txn'); + + // Test pagination - get next 2 transactions + let page2 = dispatcher.get_transaction_history(Option::None, 2, 2); + assert(page2.len() == 2, 'Page 2 should have 2 txn'); + + // Test pagination - get last transaction + let page3 = dispatcher.get_transaction_history(Option::None, 4, 2); + assert(page3.len() == 1, 'Page 3 should have 1 txn'); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_get_project_transactions() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + start_cheat_block_timestamp(contract_address, 1000); + + // Create transactions for different projects + dispatcher.create_transaction(1, recipient, 100, 'fund', 'Project 1 - Tx 1'); + dispatcher.create_transaction(2, recipient, 200, 'fund', 'Project 2 - Tx 1'); + dispatcher.create_transaction(1, recipient, 150, 'payment', 'Project 1 - Tx 2'); + dispatcher.create_transaction(2, recipient, 250, 'payment', 'Project 2 - Tx 2'); + dispatcher.create_transaction(1, recipient, 175, 'refund', 'Project 1 - Tx 3'); + + // Get transactions for project 1 + let project1_txs = dispatcher.get_project_transactions(1, 0, 10); + assert(project1_txs.len() == 3, 'Project 1 should have 3 txn'); + + // Get transactions for project 2 + let project2_txs = dispatcher.get_project_transactions(2, 0, 10); + assert(project2_txs.len() == 2, 'Project 2 should have 2 txn'); + + // Test pagination for project transactions + let project1_page1 = dispatcher.get_project_transactions(1, 0, 2); + assert(project1_page1.len() == 2, 'Proj 1 page 1 should have 2 txn'); + + let project1_page2 = dispatcher.get_project_transactions(1, 2, 2); + assert(project1_page2.len() == 1, 'Proj 1 page 2 should have 1 txn'); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_get_nonexistent_transaction() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + // Try to get a non-existent transaction + let result = dispatcher.get_transaction(999); + assert(result.is_none(), 'Should return None for 0 txn'); + + // Try to get transaction with ID 0 + let result_zero = dispatcher.get_transaction(0); + assert(result_zero.is_none(), 'Should return None for txn ID 0'); +} + +#[test] +#[should_panic(expected: ('Amount must be greater than 0',))] +fn test_create_transaction_zero_amount() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + + // This should panic + dispatcher.create_transaction(1, recipient, 0, 'fund', 'Zero amount transaction'); + + stop_cheat_caller_address(contract_address); +} + +#[test] +#[should_panic(expected: ('Recipient cannot be zero addr',))] +fn test_create_transaction_zero_recipient() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let zero_address = contract_address_const::<0>(); + + start_cheat_caller_address(contract_address, sender); + + // This should panic + dispatcher.create_transaction(1, zero_address, 100, 'fund', 'Zero recipient transaction'); + + stop_cheat_caller_address(contract_address); +} + +#[test] +fn test_transaction_count_increments() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + let sender = contract_address_const::<'sender'>(); + let recipient = contract_address_const::<'recipient'>(); + + start_cheat_caller_address(contract_address, sender); + + assert(dispatcher.get_transaction_count() == 0, 'Initial count should be 0'); + + dispatcher.create_transaction(1, recipient, 100, 'fund', 'Transaction 1'); + assert(dispatcher.get_transaction_count() == 1, 'Cnt should be 1 after first txn'); + + dispatcher.create_transaction(1, recipient, 200, 'fund', 'Transaction 2'); + assert(dispatcher.get_transaction_count() == 2, 'Count should be 2 after sec txn'); + + stop_cheat_caller_address(contract_address); +} + +#[test] +fn test_empty_transaction_history() { + let contract_address = deploy_ledger_contract(); + let dispatcher = ILedgerDispatcher { contract_address }; + + // Get history when no transactions exist + let history = dispatcher.get_transaction_history(Option::None, 0, 10); + assert(history.len() == 0, 'History should be empty'); + + // Get project transactions when no transactions exist + let project_history = dispatcher.get_project_transactions(1, 0, 10); + assert(project_history.len() == 0, 'Project history should be empty'); +}