diff --git a/src/base/errors.cairo b/src/base/errors.cairo index f288189..f938a22 100644 --- a/src/base/errors.cairo +++ b/src/base/errors.cairo @@ -318,3 +318,161 @@ pub mod GovernanceErrors { /// Error triggered when zero address is provided pub const ZERO_ADDRESS: felt252 = 'GOV: invalid contract'; } + +pub mod EmergencyComponentErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'Emergency: not admin'; + /// Error triggered when contract is paused + pub const CONTRACT_PAUSED: felt252 = 'Emergency: contract is paused'; + /// Error triggered when contract is not paused + pub const CONTRACT_NOT_PAUSED: felt252 = 'Emergency: contract not paused'; + /// Error triggered when member is already banned + pub const MEMBER_ALREADY_BANNED: felt252 = 'Emergency: member is banned'; + /// Error triggered when member is not banned + pub const MEMBER_NOT_BANNED: felt252 = 'Emergency: member is not banned'; + /// Error triggered when contract is already paused + pub const ALREADY_PAUSED: felt252 = 'Emergency: contract is paused'; +} + +pub mod EmergencyErrors { + /// Error triggered when no funds available for withdrawal + pub const NO_FUNDS_TO_WITHDRAW: felt252 = 'Emergency: no funds'; + /// Error triggered when member does not exist + pub const MEMBER_NOT_EXISTS: felt252 = 'Emergency: member not found'; + /// Error triggered when member has no contributions + pub const MEMBER_NO_CONTRIBUTIONS: felt252 = 'Emergency: no contributions'; + /// Error triggered when round is not active + pub const ROUND_NOT_ACTIVE: felt252 = 'Emergency: round inactive'; + /// Error triggered when recipient address is invalid + pub const INVALID_RECIPIENT: felt252 = 'Emergency: invalid recipient'; + /// Error triggered when new recipient is not a member + pub const RECIPIENT_NOT_MEMBER: felt252 = 'Emergency: not member'; + /// Error triggered when recipient is already set + pub const RECIPIENT_ALREADY_SET: felt252 = 'Emergency: recipient set'; + /// Error triggered when token address is invalid + pub const INVALID_TOKEN_ADDRESS: felt252 = 'Emergency: invalid token'; + /// Error triggered when amount is invalid + pub const INVALID_AMOUNT: felt252 = 'Emergency: invalid amount'; + /// Error triggered when balance is insufficient + pub const INSUFFICIENT_BALANCE: felt252 = 'Emergency: insufficient'; + /// Error triggered when contract address is invalid + pub const INVALID_CONTRACT_ADDRESS: felt252 = 'Emergency: invalid contract'; + /// Error triggered when trying to migrate to self + pub const CANNOT_MIGRATE_TO_SELF: felt252 = 'Emergency: migrate to self'; + /// Error triggered when migration target is invalid + pub const INVALID_MIGRATION_TARGET: felt252 = 'Emergency: invalid target'; + /// Error triggered when no funds to migrate + pub const NO_FUNDS_TO_MIGRATE: felt252 = 'Emergency: no funds'; +} + +pub mod PenaltyComponentErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'Penalty: not admin'; + /// Error triggered when contract is paused + pub const CONTRACT_PAUSED: felt252 = 'Penalty: contract is paused'; + /// Error triggered when contract is not paused + pub const CONTRACT_NOT_PAUSED: felt252 = 'Penalty: contract not paused'; + /// Error triggered when penalty pool is disabled + pub const PENALTY_POOL_DISABLED: felt252 = 'Penalty: penalty pool disabled'; + /// Error triggered when no contribution for round + pub const NO_CONTRIBUTION_FOR_ROUND: felt252 = 'Penalty: no contribution'; + /// Error triggered when not late + pub const NOT_LATE: felt252 = 'Penalty: not late'; +} + +pub mod AutoScheduleErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'AutoSchedule: not admin'; + /// Error triggered when contract is paused + pub const CONTRACT_PAUSED: felt252 = 'AutoSchedule: paused'; + /// Error triggered when contract is not paused + pub const CONTRACT_NOT_PAUSED: felt252 = 'AutoSchedule: not paused'; + /// Error triggered when new deadline is not in the future + pub const NEW_DEADLINE_NOT_IN_FUTURE: felt252 = 'AutoSchedule: not future'; + /// Error triggered when new deadline is not after start + pub const NEW_DEADLINE_NOT_AFTER_START: felt252 = 'AutoSchedule: not after start'; +} + +pub mod ContributionErrors { + /// Error triggered when caller is not a member + pub const NOT_MEMBER: felt252 = 'Contribution: not member'; + /// Error triggered when member already contributed + pub const ALREADY_CONTRIBUTED: felt252 = 'Contribution: contributed'; + /// Error triggered when round is not active + pub const ROUND_NOT_ACTIVE: felt252 = 'Contribution: not active'; + /// Error triggered when contribution deadline passed + pub const CONTRIBUTION_DEADLINE_PASSED: felt252 = 'Contribution: deadline passed'; + /// Error triggered when contribution amount is insufficient + pub const INSUFFICIENT_AMOUNT: felt252 = 'Contribution: insufficient'; + /// Error triggered when contribution limit exceeded + pub const CONTRIBUTION_LIMIT_EXCEEDED: felt252 = 'Contribution: limit exceeded'; + /// Error triggered when recipient is not a member + pub const RECIPIENT_NOT_MEMBER: felt252 = 'Contribution: not member'; + /// Error triggered when deadline is not in future + pub const DEADLINE_NOT_IN_FUTURE: felt252 = 'Contribution: not future'; + /// Error triggered when round deadline not passed + pub const ROUND_DEADLINE_NOT_PASSED: felt252 = 'Contribution: not deadline '; + /// Error triggered when round not completed + pub const ROUND_NOT_COMPLETED: felt252 = 'Contribution: not completed'; + /// Error triggered when address is invalid + pub const INVALID_ADDRESS: felt252 = 'Contribution: invalid'; + /// Error triggered when already a member + pub const ALREADY_MEMBER: felt252 = 'Contribution: already member'; + /// Error triggered when caller is not owner + pub const NOT_OWNER: felt252 = 'Contribution: not owner'; +} + +pub mod PaymentFlexibilityErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'Payment: not admin'; + /// Error triggered when auto-payment is disabled + pub const AUTO_PAYMENT_DISABLED: felt252 = 'Payment: auto disabled'; + /// Error triggered when token is not supported + pub const INVALID_TOKEN: felt252 = 'Payment: invalid token'; + /// Error triggered when amount is invalid + pub const INVALID_AMOUNT: felt252 = 'Payment: invalid amount'; + /// Error triggered when payment not found + pub const PAYMENT_NOT_FOUND: felt252 = 'Payment: not found'; + /// Error triggered when grace period expired + pub const GRACE_PERIOD_EXPIRED: felt252 = 'Payment: grace expired'; + /// Error triggered when insufficient allowance + pub const INSUFFICIENT_ALLOWANCE: felt252 = 'Payment: no allowance'; + /// Error triggered when oracle error occurs + pub const ORACLE_ERROR: felt252 = 'Payment: oracle error'; + /// Error triggered when round not found + pub const ROUND_NOT_FOUND: felt252 = 'Payment: round not found'; + /// Error triggered when auto-payment already active + pub const AUTO_PAYMENT_ACTIVE: felt252 = 'Payment: already active'; + /// Error triggered when frequency is invalid + pub const INVALID_FREQUENCY: felt252 = 'Payment: invalid frequency'; +} + +pub mod AnalyticsComponentErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'Analytics: not admin'; + /// Error triggered when analytics is disabled + pub const ANALYTICS_DISABLED: felt252 = 'Analytics: disabled'; + /// Error triggered when member not found + pub const MEMBER_NOT_FOUND: felt252 = 'Analytics: member not found'; + /// Error triggered when round not found + pub const ROUND_NOT_FOUND: felt252 = 'Analytics: round not found'; + /// Error triggered when period is invalid + pub const INVALID_PERIOD: felt252 = 'Analytics: invalid period'; + /// Error triggered when insufficient data + pub const INSUFFICIENT_DATA: felt252 = 'Analytics: insufficient data'; +} + +pub mod MemberProfileComponentErrors { + /// Error triggered when caller is not an Admin or higher + pub const NOT_ADMIN: felt252 = 'MemberProfile: not admin'; + /// Error triggered when profile not found + pub const PROFILE_NOT_FOUND: felt252 = 'MemberProfile: invalid profile'; + /// Error triggered when profile already exists + pub const PROFILE_ALREADY_EXISTS: felt252 = 'MemberProfile: profile exists'; + /// Error triggered when rating is invalid + pub const INVALID_RATING: felt252 = 'MemberProfile: invalid rating'; + /// Error triggered when preferences are invalid + pub const INVALID_PREFERENCES: felt252 = 'MemberProfile: invalid pref'; + /// Error triggered when member not on waitlist + pub const MEMBER_NOT_ON_WAITLIST: felt252 = 'MemberProfile: not on waitlist'; +} diff --git a/src/base/events.cairo b/src/base/events.cairo index 3f9e6da..ece1255 100644 --- a/src/base/events.cairo +++ b/src/base/events.cairo @@ -408,3 +408,86 @@ pub struct UpdateCancelled { #[key] pub key: felt252, } + +#[derive(Drop, starknet::Event)] +pub struct EmergencyWithdrawalAll { + pub total_amount: u256, + pub member_count: u32, + pub executed_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct EmergencyWithdrawalMember { + pub member: ContractAddress, + pub amount: u256, + pub executed_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct RoundEmergencyCompleted { + pub round_id: u256, + pub recipient: ContractAddress, + pub amount: u256, + pub completed_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct RoundEmergencyCancelled { + pub round_id: u256, + pub cancelled_by: ContractAddress, + pub reason: felt252, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct RecipientChanged { + pub round_id: u256, + pub old_recipient: ContractAddress, + pub new_recipient: ContractAddress, + pub changed_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct TokensRecovered { + pub token: ContractAddress, + pub amount: u256, + pub recovered_by: ContractAddress, + pub recipient: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct FundsMigrated { + pub new_contract: ContractAddress, + pub amount: u256, + pub migrated_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct PenaltyPoolDistributed { + pub total_amount: u256, + pub recipient_count: u32, + pub distribution_type: felt252, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct MemberUnbanned { + pub member: ContractAddress, + pub unbanned_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct MemberBanned { + pub member: ContractAddress, + pub reason: felt252, + pub strikes: u32, + pub banned_by: ContractAddress, + pub timestamp: u64, +} diff --git a/src/base/types.cairo b/src/base/types.cairo index 2f25179..02c0f08 100644 --- a/src/base/types.cairo +++ b/src/base/types.cairo @@ -1,3 +1,5 @@ +use core::array::{Array, ArrayTrait}; +use core::serde::Serde; use starknet::ContractAddress; @@ -215,6 +217,8 @@ pub struct ContributionRound { pub recipient: ContractAddress, /// Deadline for contributions pub deadline: u64, + /// Timestamp when the round was actually completed (0 if not completed) + pub completed_at: u64, /// Total contributions collected pub total_contributions: u256, /// Current status of the round @@ -255,6 +259,7 @@ pub struct SavingsGroup { // Enum for the status of a contribution round #[derive(Copy, Drop, Serde, PartialEq, starknet::Store, Debug)] pub enum RoundStatus { + Scheduled, #[default] Active, Completed, @@ -319,3 +324,239 @@ pub struct ParameterHistory { pub changed_by: ContractAddress, pub changed_at: u64, } + +// Penalty configuration structure +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct PenaltyConfig { + pub late_fee_percentage: u256, // Late fee as basis points (e.g., 250 = 2.5%) + pub grace_period_hours: u64, // Grace period before late fees apply + pub max_strikes: u32, // Maximum strikes before automatic ban + pub security_deposit_multiplier: u256, // Security deposit amount in tokens + pub penalty_pool_enabled: bool // Whether penalty pool distribution is enabled +} + +// Member penalty record structure +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct MemberPenaltyRecord { + pub total_penalties_paid: u256, // Total penalties paid by member + pub strikes: u32, // Current strike count + pub is_banned: bool, // Whether member is currently banned + pub last_penalty_date: u64, // Timestamp of last penalty + pub last_strike_date: u64, // Timestamp of last strike + pub total_rounds_missed: u32 // Total rounds where contribution was missed +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct AutoScheduleConfig { + pub round_duration_days: u64, + pub start_date: u64, + pub auto_activation_enabled: bool, + pub auto_completion_enabled: bool, + pub rolling_schedule_count: u8 // Maintain 2-3 future rounds +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct ScheduledRound { + pub round_id: u256, + pub recipient: ContractAddress, + pub scheduled_start: u64, + pub scheduled_deadline: u64, + pub status: RoundStatus, + pub auto_generated: bool, +} + +// Penalty event structure for history tracking +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct PenaltyEventRecord { + pub member: ContractAddress, // Member who received penalty + pub round_id: u256, // Round where penalty occurred + pub event_type: PenaltyEventType, // Type of penalty event + pub amount: u256, // Penalty amount + pub timestamp: u64, // When penalty occurred + pub admin: ContractAddress // Admin who applied penalty +} + +// Distribution data structure for penalty pool distribution +// Note: This struct is not stored, only used for calculations +#[derive(Clone, Drop, Serde)] +pub struct DistributionData { + pub total_amount: u256, // Total penalty pool amount + pub member_shares: Array, // Array of member shares + pub total_compliant_contributions: u256 // Total contributions from compliant members +} + +// Individual member share structure +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct MemberShare { + pub member: ContractAddress, // Member address + pub share: u256, // Share amount to receive + pub contribution: u256 // Member's total contribution +} + +// Penalty event types +#[allow(starknet::store_no_default_variant)] +#[derive(Copy, Drop, Serde, starknet::Store)] +pub enum PenaltyEventType { + LateFee, + Strike, + Ban, + Unban, + StrikeRemoved, +} + +#[derive(Copy, Drop, starknet::Store)] +pub struct RoundData { + pub deadline: u64, + pub completed_at: u64, + pub status: RoundStatus, + pub total_contributions: u256, +} + +// Data structures for payment flexibility functionality +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct PaymentConfig { + pub grace_period_hours: u64, + pub early_payment_discount_basis_points: u256, // E.g., 500 for 5% + pub auto_payment_enabled: bool, + pub usd_oracle_address: ContractAddress, + pub max_grace_period_extension: u64, // Maximum extension in hours + pub min_early_payment_days: u64 // Minimum days before deadline for early payment +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub enum PaymentFrequency { + Once, + Daily, + Weekly, + Monthly, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct AutoPaymentSetup { + pub member: ContractAddress, + pub token: ContractAddress, + pub amount: u256, + pub frequency: PaymentFrequency, + pub next_payment_date: u64, + pub is_active: bool, + pub created_at: u64, + pub last_payment_date: u64, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub enum PaymentStatus { + Pending, + Paid, + Late, + Missed, + PaidAfterGrace, + Overpaid, + Early, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct PaymentRecord { + pub member: ContractAddress, + pub round_id: u256, + pub amount: u256, + pub token: ContractAddress, + pub payment_date: u64, + pub status: PaymentStatus, + pub is_early_payment: bool, + pub discount_applied: u256, + pub grace_period_used: u64, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct EarlyPaymentInfo { + pub member: ContractAddress, + pub round_id: u256, + pub original_amount: u256, + pub discount_amount: u256, + pub final_amount: u256, + pub payment_date: u64, +} + +// Data structures for analytics functionality +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct ContributionAnalytics { + pub total_rounds: u256, + pub successful_rounds: u256, + pub failed_rounds: u256, + pub average_completion_time: u64, + pub total_penalties_collected: u256, + pub total_contributions: u256, + pub last_updated: u64, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct MemberAnalytics { + pub total_contributions: u256, + pub on_time_payments: u256, + pub late_payments: u256, + pub missed_payments: u256, + pub reliability_score: u8, + pub last_updated: u64, + pub average_contribution_amount: u256, + pub total_rounds_participated: u256, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct RoundPerformanceMetrics { + pub round_id: u256, + pub completion_rate: u8, + pub average_delay: u64, + pub total_fees_collected: u256, + pub success_status: RoundSuccessStatus, + pub total_contributions: u256, + pub participant_count: u32, + pub completion_time: u64, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub enum RoundSuccessStatus { + Outstanding, + Good, + Average, + Poor, + Failed, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct FinancialReport { + pub period_start: u64, + pub period_end: u64, + pub total_contributions: u256, + pub total_fees_collected: u256, + pub total_penalties_collected: u256, + pub active_members: u32, + pub rounds_completed: u32, + pub average_round_completion_time: u64, + pub system_uptime_percentage: u8, +} + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct SystemHealthMetrics { + pub system_uptime_percentage: u8, + pub active_rounds: u32, + pub total_locked_value: u256, + pub security_score: u8, + pub member_satisfaction_score: u8, + pub last_health_check: u64, +} + +/// Member profile structure for savings group members +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct MemberProfileData { + pub join_date: u64, + pub total_contributions: u256, + pub missed_contributions: u8, + pub credit_score: u8, + pub last_recipient_round: u256, + pub reliability_rating: u8, + pub preferred_payment_method: felt252, + pub communication_preferences: felt252, + pub is_on_waitlist: bool, + pub waitlist_position: u32, + pub last_message_timestamp: u64, +} diff --git a/src/component/analytics.cairo b/src/component/analytics.cairo new file mode 100644 index 0000000..f2cddec --- /dev/null +++ b/src/component/analytics.cairo @@ -0,0 +1,532 @@ +use starknet::ContractAddress; +use starkremit_contract::base::types::{ + ContributionAnalytics, FinancialReport, MemberAnalytics, MemberContribution, RoundData, + RoundPerformanceMetrics, RoundStatus, RoundSuccessStatus, SystemHealthMetrics, +}; + +// Trait that the main contract must implement to provide data access +pub trait IMainContractData { + fn get_round_data(self: @TContractState, round_id: u256) -> RoundData; + fn get_member_contribution_data( + self: @TContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution; + fn get_member_status(self: @TContractState, member: ContractAddress) -> bool; + fn get_member_count(self: @TContractState) -> u32; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; + fn get_round_ids(self: @TContractState) -> u256; +} + +#[starknet::interface] +pub trait IAnalytics { + // Configuration and query functions (simple operations) + fn get_contribution_analytics(self: @TContractState) -> ContributionAnalytics; + fn get_member_analytics(self: @TContractState, member: ContractAddress) -> MemberAnalytics; + fn get_round_performance(self: @TContractState, round_id: u256) -> RoundPerformanceMetrics; + fn get_system_health(self: @TContractState) -> SystemHealthMetrics; + fn generate_financial_report( + self: @TContractState, period_start: u64, period_end: u64, + ) -> FinancialReport; + + // Utility functions (simple operations) + fn get_member_reliability_score(self: @TContractState, member: ContractAddress) -> u8; + fn get_round_success_rate(self: @TContractState, round_id: u256) -> u8; + fn get_total_system_value(self: @TContractState) -> u256; +} + + +#[starknet::component] +pub mod analytics_component { + use core::array::ArrayTrait; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use starkremit_contract::base::errors::AnalyticsComponentErrors; + use super::*; + + #[storage] + pub struct Storage { + contribution_analytics: ContributionAnalytics, + member_analytics: Map, + round_metrics: Map, + financial_reports: Map, // timestamp -> report + system_metrics: SystemHealthMetrics, + admin: ContractAddress, + last_update_timestamp: u64, + total_system_value: u256, + analytics_enabled: bool, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + AnalyticsUpdated: AnalyticsUpdated, + MemberAnalyticsUpdated: MemberAnalyticsUpdated, + RoundMetricsUpdated: RoundMetricsUpdated, + FinancialReportGenerated: FinancialReportGenerated, + SystemHealthUpdated: SystemHealthUpdated, + AnalyticsConfigUpdated: AnalyticsConfigUpdated, + } + + #[derive(Drop, starknet::Event)] + pub struct AnalyticsUpdated { + admin: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MemberAnalyticsUpdated { + member: ContractAddress, + reliability_score: u8, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RoundMetricsUpdated { + round_id: u256, + completion_rate: u8, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct FinancialReportGenerated { + period_start: u64, + period_end: u64, + total_contributions: u256, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct SystemHealthUpdated { + security_score: u8, + uptime_percentage: u8, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct AnalyticsConfigUpdated { + admin: ContractAddress, + enabled: bool, + timestamp: u64, + } + + #[embeddable_as(Analytics)] + pub impl AnalyticsImpl< + TContractState, +HasComponent, +IMainContractData, + > of super::IAnalytics> { + fn get_contribution_analytics( + self: @ComponentState, + ) -> ContributionAnalytics { + self.contribution_analytics.read() + } + + fn get_member_analytics( + self: @ComponentState, member: ContractAddress, + ) -> MemberAnalytics { + self.member_analytics.read(member) + } + + fn get_round_performance( + self: @ComponentState, round_id: u256, + ) -> RoundPerformanceMetrics { + self.round_metrics.read(round_id) + } + + fn get_system_health(self: @ComponentState) -> SystemHealthMetrics { + self.system_metrics.read() + } + + fn generate_financial_report( + self: @ComponentState, period_start: u64, period_end: u64, + ) -> FinancialReport { + self._generate_financial_report(period_start, period_end) + } + + fn get_member_reliability_score( + self: @ComponentState, member: ContractAddress, + ) -> u8 { + let analytics = self.member_analytics.read(member); + analytics.reliability_score + } + + fn get_round_success_rate(self: @ComponentState, round_id: u256) -> u8 { + let metrics = self.round_metrics.read(round_id); + metrics.completion_rate + } + + fn get_total_system_value(self: @ComponentState) -> u256 { + self.total_system_value.read() + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, +IMainContractData, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.admin.write(admin); + self.analytics_enabled.write(true); + self.last_update_timestamp.write(get_block_timestamp()); + + // Initialize default analytics + let default_analytics = ContributionAnalytics { + total_rounds: 0, + successful_rounds: 0, + failed_rounds: 0, + average_completion_time: 0, + total_penalties_collected: 0, + total_contributions: 0, + last_updated: get_block_timestamp(), + }; + self.contribution_analytics.write(default_analytics); + + // Initialize system health metrics + let default_health = SystemHealthMetrics { + system_uptime_percentage: 100, + active_rounds: 0, + total_locked_value: 0, + security_score: 100, + member_satisfaction_score: 100, + last_health_check: get_block_timestamp(), + }; + self.system_metrics.write(default_health); + + // Initialize total system value + self.total_system_value.write(0); + } + + // Internal function to assert that the caller is the admin + fn _assert_admin(self: @ComponentState) { + let admin: ContractAddress = self.admin.read(); + let caller: ContractAddress = get_caller_address(); + assert(caller == admin, AnalyticsComponentErrors::NOT_ADMIN); + } + + // Complex operations that will be called by the main contract + fn _update_member_analytics( + ref self: ComponentState, + member: ContractAddress, + payment_made: bool, + amount: u256, + round_id: u256, + ) { + let contract_state = self.get_contract(); + let mut analytics = self.member_analytics.read(member); + + // Update contribution statistics + analytics.total_contributions += amount; + analytics.total_rounds_participated += 1; + + if payment_made { + analytics.on_time_payments += 1; + } else { + analytics.missed_payments += 1; + } + + // Calculate average contribution amount + if analytics.total_rounds_participated > 0 { + analytics.average_contribution_amount = analytics.total_contributions + / analytics.total_rounds_participated; + } + + // Update reliability score + analytics + .reliability_score = self + ._calculate_reliability_score( + analytics.on_time_payments, analytics.total_rounds_participated, + ); + analytics.last_updated = get_block_timestamp(); + + self.member_analytics.write(member, analytics); + + self + .emit( + Event::MemberAnalyticsUpdated( + MemberAnalyticsUpdated { + member, + reliability_score: analytics.reliability_score, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _update_round_metrics( + ref self: ComponentState, + round_id: u256, + status: RoundStatus, + total_contributions: u256, + participant_count: u32, + ) { + let contract_state = self.get_contract(); + let round = contract_state.get_round_data(round_id); + let current_time = get_block_timestamp(); + + let mut metrics = self.round_metrics.read(round_id); + metrics.round_id = round_id; + metrics.total_contributions = total_contributions; + metrics.participant_count = participant_count; + + // Calculate completion rate based on member count + let member_count = contract_state.get_member_count(); + if member_count > 0 { + metrics + .completion_rate = ((participant_count * 100) / member_count) + .try_into() + .unwrap(); + } + + // Calculate completion time if round is completed + if status == RoundStatus::Completed { + // Use the round's recorded completion timestamp, and compute delay vs deadline + let completed_at = round.completed_at; + metrics.completion_time = completed_at; + if completed_at > 0 { + if completed_at > round.deadline { + metrics.average_delay = completed_at - round.deadline; + } else { + metrics.average_delay = 0; + } + } + } + + // Determine success status + metrics.success_status = self._determine_success_status(metrics.completion_rate); + + self.round_metrics.write(round_id, metrics); + + self + .emit( + Event::RoundMetricsUpdated( + RoundMetricsUpdated { + round_id, + completion_rate: metrics.completion_rate, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _update_contribution_analytics(ref self: ComponentState) { + let contract_state = self.get_contract(); + let mut analytics = self.contribution_analytics.read(); + + let total_rounds = contract_state.get_round_ids(); + let mut successful_rounds = 0; + let mut failed_rounds = 0; + let mut total_completion_time = 0; + let mut completed_rounds_count = 0; + + // Calculate round statistics + let mut round_id = 1; + while round_id <= total_rounds { + let round = contract_state.get_round_data(round_id); + if round.status == RoundStatus::Completed { + successful_rounds += 1; + // Use recorded round metrics completion time if available + let metrics = self.round_metrics.read(round_id); + if metrics.completion_time > 0 { + total_completion_time += metrics.completion_time; + completed_rounds_count += 1; + } + } else if round.status == RoundStatus::Cancelled { + failed_rounds += 1; + } + round_id += 1; + } + + analytics.total_rounds = total_rounds; + analytics.successful_rounds = successful_rounds; + analytics.failed_rounds = failed_rounds; + + if completed_rounds_count > 0 { + analytics.average_completion_time = total_completion_time / completed_rounds_count; + } + + analytics.last_updated = get_block_timestamp(); + self.contribution_analytics.write(analytics); + + self + .emit( + Event::AnalyticsUpdated( + AnalyticsUpdated { + admin: get_caller_address(), timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _update_system_health(ref self: ComponentState) { + let contract_state = self.get_contract(); + let mut health = self.system_metrics.read(); + + // Calculate active rounds + let total_rounds = contract_state.get_round_ids(); + let mut active_rounds = 0; + let mut round_id = 1; + while round_id <= total_rounds { + let round = contract_state.get_round_data(round_id); + if round.status == RoundStatus::Active { + active_rounds += 1; + } + round_id += 1; + } + + health.active_rounds = active_rounds.try_into().unwrap(); + health.total_locked_value = self.total_system_value.read(); + health.last_health_check = get_block_timestamp(); + + // Calculate security score based on various factors + health.security_score = self._calculate_security_score(); + + self.system_metrics.write(health); + + self + .emit( + Event::SystemHealthUpdated( + SystemHealthUpdated { + security_score: health.security_score, + uptime_percentage: health.system_uptime_percentage, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _generate_financial_report( + self: @ComponentState, period_start: u64, period_end: u64, + ) -> FinancialReport { + let contract_state = self.get_contract(); + let analytics = self.contribution_analytics.read(); + + let report = FinancialReport { + period_start, + period_end, + total_contributions: analytics.total_contributions, + total_fees_collected: analytics.total_penalties_collected, // Simplified + total_penalties_collected: analytics.total_penalties_collected, + active_members: contract_state.get_member_count(), + rounds_completed: analytics.successful_rounds.try_into().unwrap(), + average_round_completion_time: analytics.average_completion_time, + system_uptime_percentage: 100 // Simplified + }; + + report + } + + // Helper functions + fn _calculate_reliability_score( + self: @ComponentState, on_time_payments: u256, total_rounds: u256, + ) -> u8 { + if total_rounds == 0 { + return 100; + } + + let score = (on_time_payments * 100) / total_rounds; + score.try_into().unwrap_or(100) + } + + fn _determine_success_status( + self: @ComponentState, completion_rate: u8, + ) -> RoundSuccessStatus { + if completion_rate >= 95 { + RoundSuccessStatus::Outstanding + } else if completion_rate >= 85 { + RoundSuccessStatus::Good + } else if completion_rate >= 70 { + RoundSuccessStatus::Average + } else if completion_rate >= 50 { + RoundSuccessStatus::Poor + } else { + RoundSuccessStatus::Failed + } + } + + fn _calculate_security_score(self: @ComponentState) -> u8 { + // Weighted security score based on available metrics + // Metrics used: + // - System uptime percentage (positive weight) + // - Average round completion rate (positive weight) + // - Verified/active members percentage via get_member_status (positive weight) + // - Average member reliability score (positive weight) + // Weights: uptime 30, completion 25, verified 25, reliability 20 + + let contract_state = self.get_contract(); + + // Uptime (0..100) + let health = self.system_metrics.read(); + let uptime_score: u8 = health.system_uptime_percentage; + + // Average round completion rate (0..100) + let total_rounds: u256 = contract_state.get_round_ids(); + let mut completion_sum: u256 = 0; + let mut completion_count: u256 = 0; + let mut r_id: u256 = 1; + while r_id <= total_rounds { + let rm = self.round_metrics.read(r_id); + // Only count rounds that have a non-zero recorded completion rate + if rm.completion_rate > 0_u8 { + completion_sum += (rm.completion_rate).into(); + completion_count += 1; + } + r_id += 1; + } + let completion_avg_u8: u8 = if completion_count > 0 { + let avg: u256 = completion_sum / completion_count; + avg.try_into().unwrap_or(0_u8) + } else { + 50_u8 // neutral default when there are no rounds + }; + + // Verified/active members percentage (0..100) + let members_total: u32 = contract_state.get_member_count(); + let mut verified_count: u32 = 0; + let mut idx: u32 = 0; + while idx < members_total { + let addr = contract_state.get_member_by_index(idx); + if contract_state.get_member_status(addr) { + verified_count += 1; + } + idx += 1; + } + let verified_pct_u8: u8 = if members_total > 0 { + let pct: u32 = (verified_count * 100_u32) / members_total; + pct.try_into().unwrap_or(0_u8) + } else { + 50_u8 // neutral default when there are no members + }; + + // Average member reliability (0..100) + let mut reliability_sum: u256 = 0; + let mut m_idx: u32 = 0; + while m_idx < members_total { + let m_addr = contract_state.get_member_by_index(m_idx); + let ma = self.member_analytics.read(m_addr); + reliability_sum += (ma.reliability_score).into(); + m_idx += 1; + } + let reliability_avg_u8: u8 = if members_total > 0 { + let avg_rel: u256 = reliability_sum / (members_total.into()); + avg_rel.try_into().unwrap_or(0_u8) + } else { + 50_u8 + }; + + // Weighted combination; use u256 for intermediate math + let w_uptime: u256 = (uptime_score).into() * 30_u256; + let w_completion: u256 = (completion_avg_u8).into() * 25_u256; + let w_verified: u256 = (verified_pct_u8).into() * 25_u256; + let w_reliability: u256 = (reliability_avg_u8).into() * 20_u256; + let total_weighted: u256 = w_uptime + w_completion + w_verified + w_reliability; + let mut combined: u256 = total_weighted / 100_u256; + + // Clamp to 0..100 + if combined > 100_u256 { + combined = 100_u256; + } + + combined.try_into().unwrap_or(100_u8) + } + } +} diff --git a/src/component/auto_schedule.cairo b/src/component/auto_schedule.cairo new file mode 100644 index 0000000..cde64d0 --- /dev/null +++ b/src/component/auto_schedule.cairo @@ -0,0 +1,466 @@ +use starknet::ContractAddress; +use starkremit_contract::base::types::{AutoScheduleConfig, RoundStatus, ScheduledRound}; + + +// Trait that the main contract must implement to provide data access +pub trait IMainContractData { + fn get_member_count(self: @TContractState) -> u32; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; + fn get_current_round_id(self: @TContractState) -> u256; + fn create_round(ref self: TContractState, recipient: ContractAddress, deadline: u64) -> u256; +} + +#[starknet::interface] +pub trait IAutoSchedule { + // Configuration and query functions (simple operations) + fn get_config(self: @TContractState) -> AutoScheduleConfig; + fn get_scheduled_round(self: @TContractState, round_id: u256) -> ScheduledRound; + fn get_next_scheduled_rounds(self: @TContractState, count: u8) -> Array; + fn get_current_rotation_index(self: @TContractState) -> u32; + + fn is_auto_schedule_enabled(self: @TContractState) -> bool; + fn get_rotation_length(self: @TContractState) -> u32; +} + +#[starknet::component] +pub mod auto_schedule_component { + use core::array::ArrayTrait; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use starkremit_contract::base::errors::AutoScheduleErrors; + use starkremit_contract::base::types::RoundStatus; + use super::{AutoScheduleConfig, IMainContractData, ScheduledRound}; + + const SECONDS_PER_DAY: u64 = 86400; + + #[storage] + pub struct Storage { + config: AutoScheduleConfig, + scheduled_rounds: Map, + member_rotation: Map, // Index -> Address + rotation_length: u32, + current_rotation_index: u32, + next_round_id: u256, + last_processed_round: u256, + last_processed_timestamp: u64, + admin: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + AutoScheduleSetup: AutoScheduleSetup, + RoundAutoActivated: RoundAutoActivated, + RoundAutoCompleted: RoundAutoCompleted, + ScheduleMaintained: ScheduleMaintained, + RoundScheduleModified: RoundScheduleModified, + ConfigUpdated: ConfigUpdated, + ScheduleProcessed: ScheduleProcessed, + } + + #[derive(Drop, starknet::Event)] + pub struct AutoScheduleSetup { + admin: ContractAddress, + start_date: u64, + round_duration_days: u64, + rolling_schedule_count: u8, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RoundAutoActivated { + round_id: u256, + recipient: ContractAddress, + scheduled_start: u64, + scheduled_deadline: u64, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RoundAutoCompleted { + round_id: u256, + completed_at: u64, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ScheduleMaintained { + rounds_created: u32, + last_maintenance_timestamp: u64, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RoundScheduleModified { + round_id: u256, + old_deadline: u64, + new_deadline: u64, + modified_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ConfigUpdated { + old_config: AutoScheduleConfig, + new_config: AutoScheduleConfig, + updated_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ScheduleProcessed { + rounds_processed: u32, + more_work_remaining: bool, + timestamp: u64, + } + + #[embeddable_as(AutoSchedule)] + impl AutoScheduleImpl< + TContractState, +HasComponent, +IMainContractData, + > of super::IAutoSchedule> { + fn get_config(self: @ComponentState) -> AutoScheduleConfig { + self.config.read() + } + + fn get_scheduled_round( + self: @ComponentState, round_id: u256, + ) -> ScheduledRound { + self.scheduled_rounds.read(round_id) + } + + fn get_next_scheduled_rounds( + self: @ComponentState, count: u8, + ) -> Array { + let mut rounds = ArrayTrait::new(); + let current_index = self.current_rotation_index.read(); + let mut rounds_added = 0_u8; + + // Get next scheduled rounds + let mut i = 1_u256; + while i <= self.next_round_id.read() && rounds_added < count.into() { + let scheduled_round = self.scheduled_rounds.read(i); + if scheduled_round.status == RoundStatus::Scheduled { + rounds.append(scheduled_round); + rounds_added += 1_u8; + } + i += 1_u256; + } + + rounds + } + + fn get_current_rotation_index(self: @ComponentState) -> u32 { + self.current_rotation_index.read() + } + + fn is_auto_schedule_enabled(self: @ComponentState) -> bool { + let config = self.config.read(); + config.auto_activation_enabled + } + + fn get_rotation_length(self: @ComponentState) -> u32 { + self.rotation_length.read() + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, +IMainContractData, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.admin.write(admin); + + // Set default auto-schedule configuration + let default_config = AutoScheduleConfig { + round_duration_days: 30, + start_date: get_block_timestamp(), + auto_activation_enabled: true, + auto_completion_enabled: true, + rolling_schedule_count: 3, + }; + self.config.write(default_config); + + // Initialize rotation system + self.rotation_length.write(0); + self.current_rotation_index.write(0); + self.next_round_id.write(1); + self.last_processed_round.write(1); + self.last_processed_timestamp.write(get_block_timestamp()); + + self + .emit( + Event::AutoScheduleSetup( + AutoScheduleSetup { + admin, + start_date: default_config.start_date, + round_duration_days: default_config.round_duration_days, + rolling_schedule_count: default_config.rolling_schedule_count, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + // Internal function to assert that the caller is the admin + fn _assert_admin(self: @ComponentState) { + let admin: ContractAddress = self.admin.read(); + let caller: ContractAddress = get_caller_address(); + assert(caller == admin, AutoScheduleErrors::NOT_ADMIN); + } + + // Complex operations that will be called by the main contract + fn _setup_auto_schedule( + ref self: ComponentState, config: AutoScheduleConfig, + ) { + self._assert_admin(); + + let old_config = self.config.read(); + self.config.write(config); + + // Initialize member rotation if not already set + if self.rotation_length.read() == 0 { + self._initialize_member_rotation(); + } + + self + .emit( + Event::ConfigUpdated( + ConfigUpdated { + old_config, + new_config: config, + updated_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _maintain_rolling_schedule(ref self: ComponentState) { + let config = self.config.read(); + let current_time = get_block_timestamp(); + let last_maintenance = self.last_processed_timestamp.read(); + + // Check if maintenance is needed + if current_time - last_maintenance < config.round_duration_days * SECONDS_PER_DAY { + return; + } + + let mut rounds_created = 0_u32; + let mut current_index: u256 = self.next_round_id.read(); + + // Create new rounds to maintain rolling schedule + while rounds_created < config.rolling_schedule_count.into() { + current_index += 1_u256; + + // Calculate timing for the new round + let round_duration_seconds: u64 = config.round_duration_days * SECONDS_PER_DAY; + let round_start = config.start_date + + ((current_index - 1_u256).try_into().unwrap() * round_duration_seconds); + let round_deadline = round_start + round_duration_seconds; + + // Determine recipient by rotating through the member list + let recipient = self._get_next_recipient(); + + let scheduled_round = ScheduledRound { + round_id: current_index, + recipient, + scheduled_start: round_start, + scheduled_deadline: round_deadline, + status: RoundStatus::Scheduled, + auto_generated: true, + }; + + self.scheduled_rounds.write(current_index, scheduled_round); + rounds_created += 1; + } + + // Update indices for the next maintenance cycle + self.next_round_id.write(current_index); + self.last_processed_timestamp.write(current_time); + + self + .emit( + Event::ScheduleMaintained( + ScheduleMaintained { + rounds_created: rounds_created.try_into().unwrap(), + last_maintenance_timestamp: current_time, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _auto_activate_round(ref self: ComponentState, round_id: u256) { + let config = self.config.read(); + if !config.auto_activation_enabled { + return; + } + + let mut scheduled_round = self.scheduled_rounds.read(round_id); + if scheduled_round.status == RoundStatus::Scheduled { + scheduled_round.status = RoundStatus::Active; + self.scheduled_rounds.write(round_id, scheduled_round); + + self + .emit( + Event::RoundAutoActivated( + RoundAutoActivated { + round_id, + recipient: scheduled_round.recipient, + scheduled_start: scheduled_round.scheduled_start, + scheduled_deadline: scheduled_round.scheduled_deadline, + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + fn _auto_complete_expired_rounds( + ref self: ComponentState, max_iterations: u32, + ) -> (u32, bool) { + let config = self.config.read(); + if !config.auto_completion_enabled { + return (0, false); + } + + let current_time = get_block_timestamp(); + let mut rounds_processed = 0_u32; + + // Determine batch limit + let default_limit: u32 = 50; + let limit: u32 = if max_iterations == 0 { + default_limit + } else { + max_iterations + }; + + // Iterate starting from the last processed cursor with wrap-around + let next_round_id = self.next_round_id.read(); + if next_round_id == 0_u256 { + return (0, false); + } + let mut i = self.last_processed_round.read(); + if i < 1_u256 { + i = 1_u256; + } + + let mut iterated: u32 = 0; + while i <= next_round_id && iterated < limit { + let mut scheduled_round = self.scheduled_rounds.read(i); + + if scheduled_round.status == RoundStatus::Active + && scheduled_round.scheduled_deadline <= current_time { + scheduled_round.status = RoundStatus::Completed; + self.scheduled_rounds.write(i, scheduled_round); + rounds_processed += 1_u32; + + self + .emit( + Event::RoundAutoCompleted( + RoundAutoCompleted { + round_id: i, + completed_at: current_time, + timestamp: get_block_timestamp(), + }, + ), + ); + } + i += 1_u256; + iterated += 1_u32; + } + + // Update cursor: wrap to 1 if we've reached the end + let mut more_work_remaining = false; + if i <= next_round_id { + more_work_remaining = true; + self.last_processed_round.write(i); + } else { + self.last_processed_round.write(1); + } + + self + .emit( + Event::ScheduleProcessed( + ScheduleProcessed { + rounds_processed, more_work_remaining, timestamp: get_block_timestamp(), + }, + ), + ); + + (rounds_processed, more_work_remaining) + } + + fn _modify_schedule( + ref self: ComponentState, round_id: u256, new_deadline: u64, + ) { + self._assert_admin(); + + let mut scheduled_round = self.scheduled_rounds.read(round_id); + let old_deadline = scheduled_round.scheduled_deadline; + + // Validate new deadline + assert( + new_deadline > get_block_timestamp(), + AutoScheduleErrors::NEW_DEADLINE_NOT_IN_FUTURE, + ); + assert( + new_deadline > scheduled_round.scheduled_start, + AutoScheduleErrors::NEW_DEADLINE_NOT_AFTER_START, + ); + + scheduled_round.scheduled_deadline = new_deadline; + self.scheduled_rounds.write(round_id, scheduled_round); + + self + .emit( + Event::RoundScheduleModified( + RoundScheduleModified { + round_id, + old_deadline, + new_deadline, + modified_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + // Helper functions + fn _initialize_member_rotation(ref self: ComponentState) { + let contract_state = self.get_contract(); + let member_count = contract_state.get_member_count(); + + if member_count > 0 { + self.rotation_length.write(member_count); + + // Populate rotation array + let mut i = 0; + while i < member_count { + let member = contract_state.get_member_by_index(i); + self.member_rotation.write(i, member); + i += 1; + } + } + } + + fn _get_next_recipient(ref self: ComponentState) -> ContractAddress { + let rotation_length = self.rotation_length.read(); + if rotation_length == 0 { + return 0.try_into().unwrap(); + } + + let current_index = self.current_rotation_index.read(); + let recipient = self.member_rotation.read(current_index); + let next_index = (current_index + 1_u32) % rotation_length; + + // Update rotation index after selecting the recipient + self.current_rotation_index.write(next_index); + + recipient + } + } +} diff --git a/src/component/contribution/contribution.cairo b/src/component/contribution/contribution.cairo index a427a95..e4c3a0f 100644 --- a/src/component/contribution/contribution.cairo +++ b/src/component/contribution/contribution.cairo @@ -1,5 +1,6 @@ use starknet::ContractAddress; -use starkremit_contract::base::types::{ContributionRound, MemberContribution}; +use starkremit_contract::base::errors::ContributionErrors; +use starkremit_contract::base::types::{ContributionRound, MemberContribution, RoundStatus}; #[starknet::interface] pub trait IContribution { @@ -20,6 +21,19 @@ pub trait IContribution { fn get_current_round_id(self: @TContractState) -> u256; fn set_required_contribution(ref self: TContractState, amount: u256); fn get_required_contribution(self: @TContractState) -> u256; + + + fn get_member_contribution_history( + self: @TContractState, member: ContractAddress, limit: u32, offset: u32, + ) -> Array; + fn get_round_statistics( + self: @TContractState, round_id: u256, + ) -> (u256, u32, u32); // total_amount, contributor_count, member_count + fn validate_contribution_eligibility( + self: @TContractState, member: ContractAddress, round_id: u256, + ) -> bool; + fn get_next_recipient(ref self: TContractState) -> ContractAddress; + fn advance_round_rotation(ref self: TContractState); } #[starknet::component] @@ -33,6 +47,7 @@ pub mod contribution_component { StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; + use starkremit_contract::base::errors::ContributionErrors; use starkremit_contract::base::types::{ContributionRound, MemberContribution, RoundStatus}; use super::*; @@ -49,6 +64,18 @@ pub mod contribution_component { required_contribution: u256, member_index_map: Map, erc20_address: ContractAddress, + // Enhanced storage + member_contribution_history: Map< + (ContractAddress, u32), u256, + >, // member -> (index -> round_id) + member_contribution_count: Map, // member -> total contributions + current_rotation_index: u32, // Current position in member rotation + round_contributor_count: Map, // round_id -> number of contributors + member_last_contribution: Map< + ContractAddress, u64, + >, // member -> last contribution timestamp + contribution_limits: Map, // member -> max contribution per round + grace_period_hours: u64 // Grace period for late contributions } #[event] @@ -61,41 +88,89 @@ pub mod contribution_component { MemberAdded: MemberAdded, MemberRemoved: MemberRemoved, RequiredContributionUpdated: RequiredContributionUpdated, + ContributionLimitUpdated: ContributionLimitUpdated, + GracePeriodUpdated: GracePeriodUpdated, + RoundRotationAdvanced: RoundRotationAdvanced, } + #[derive(Drop, starknet::Event)] pub struct ContributionMade { round_id: u256, member: ContractAddress, amount: u256, + timestamp: u64, + is_on_time: bool, } + #[derive(Drop, starknet::Event)] pub struct RoundDisbursed { round_id: u256, recipient: ContractAddress, amount: u256, + contributor_count: u32, + timestamp: u64, } + #[derive(Drop, starknet::Event)] pub struct RoundCompleted { round_id: u256, + total_amount: u256, + contributor_count: u32, + timestamp: u64, } + #[derive(Drop, starknet::Event)] pub struct ContributionMissed { round_id: u256, member: ContractAddress, + timestamp: u64, } + #[derive(Drop, starknet::Event)] pub struct MemberAdded { member: ContractAddress, + added_by: ContractAddress, + timestamp: u64, } #[derive(Drop, starknet::Event)] pub struct MemberRemoved { member: ContractAddress, + removed_by: ContractAddress, + timestamp: u64, } + #[derive(Drop, starknet::Event)] pub struct RequiredContributionUpdated { old_amount: u256, new_amount: u256, + updated_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ContributionLimitUpdated { + member: ContractAddress, + old_limit: u256, + new_limit: u256, + updated_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct GracePeriodUpdated { + old_hours: u64, + new_hours: u64, + updated_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct RoundRotationAdvanced { + old_index: u32, + new_index: u32, + next_recipient: ContractAddress, + timestamp: u64, } #[embeddable_as(Contribution)] @@ -109,27 +184,56 @@ pub mod contribution_component { ref self: ComponentState, round_id: u256, amount: u256, ) { let caller = get_caller_address(); - assert(self.is_member(caller), 'Caller is not a member'); + let current_time = get_block_timestamp(); + + // Validate caller is a member + assert(self.is_member(caller), ContributionErrors::NOT_MEMBER); // Prevent double contributions let existing_contribution = self.member_contributions.read((round_id, caller)); - assert(existing_contribution.amount == 0, 'Already contributed'); + assert(existing_contribution.amount == 0, ContributionErrors::ALREADY_CONTRIBUTED); let mut round = self.rounds.read(round_id); - assert(round.status == RoundStatus::Active, 'Round is not active'); - assert(get_block_timestamp() <= round.deadline, 'Contribution deadline passed'); + assert(round.status == RoundStatus::Active, ContributionErrors::ROUND_NOT_ACTIVE); + + // Check if contribution is within grace period + let grace_period = self.grace_period_hours.read() * 3600; // Convert to seconds + let is_on_time = current_time <= round.deadline + grace_period; + assert(is_on_time, ContributionErrors::CONTRIBUTION_DEADLINE_PASSED); // Validate contribution amount let required_amount = self.required_contribution.read(); - assert(amount >= required_amount, 'amount less than required'); + assert(amount >= required_amount, ContributionErrors::INSUFFICIENT_AMOUNT); + // Check contribution limits + let member_limit = self.contribution_limits.read(caller); + if member_limit > 0 { + assert(amount <= member_limit, ContributionErrors::CONTRIBUTION_LIMIT_EXCEEDED); + } + + // Create contribution record let contribution = MemberContribution { - member: caller, amount, contributed_at: get_block_timestamp(), + member: caller, amount, contributed_at: current_time, }; self.member_contributions.write((round_id, caller), contribution); + + // Update round statistics round.total_contributions += amount; self.rounds.write(round_id, round); + // Update contributor count + let contributor_count = self.round_contributor_count.read(round_id); + self.round_contributor_count.write(round_id, contributor_count + 1); + + // Update member statistics + let member_contribution_count = self.member_contribution_count.read(caller); + self.member_contribution_count.write(caller, member_contribution_count + 1); + self.member_last_contribution.write(caller, current_time); + + // Add to member's contribution history + let history_count = self.member_contribution_count.read(caller); + self.member_contribution_history.write((caller, history_count - 1), round_id); + // Token transfer let erc20_address = self.erc20_address.read(); IERC20Dispatcher { contract_address: erc20_address } @@ -137,7 +241,15 @@ pub mod contribution_component { self .emit( - Event::ContributionMade(ContributionMade { round_id, member: caller, amount }), + Event::ContributionMade( + ContributionMade { + round_id, + member: caller, + amount, + timestamp: current_time, + is_on_time: current_time <= round.deadline, + }, + ), ); } @@ -145,10 +257,26 @@ pub mod contribution_component { self.is_owner(); let mut round = self.rounds.read(round_id); - assert(round.status == RoundStatus::Active, 'Round is not active'); + assert(round.status == RoundStatus::Active, ContributionErrors::ROUND_NOT_ACTIVE); + + let current_time = get_block_timestamp(); round.status = RoundStatus::Completed; + round.completed_at = current_time; self.rounds.write(round_id, round); - self.emit(Event::RoundCompleted(RoundCompleted { round_id })); + + let contributor_count = self.round_contributor_count.read(round_id); + + self + .emit( + Event::RoundCompleted( + RoundCompleted { + round_id, + total_amount: round.total_contributions, + contributor_count, + timestamp: current_time, + }, + ), + ); } fn add_round_to_schedule( @@ -157,18 +285,27 @@ pub mod contribution_component { self.is_owner(); // Validate recipient is a member - assert(self.is_member(recipient), 'Recipient must be a member'); + assert(self.is_member(recipient), ContributionErrors::RECIPIENT_NOT_MEMBER); // Validate deadline is in the future - assert(deadline > get_block_timestamp(), 'Deadline not in the future'); + assert(deadline > get_block_timestamp(), ContributionErrors::DEADLINE_NOT_IN_FUTURE); let round_id = self.round_ids.read() + 1; self.round_ids.write(round_id); self.rotation_schedule.write(round_id, recipient); + let round = ContributionRound { - round_id, recipient, deadline, total_contributions: 0, status: RoundStatus::Active, + round_id, + recipient, + deadline, + completed_at: 0, + total_contributions: 0, + status: RoundStatus::Active, }; self.rounds.write(round_id, round); + + // Initialize contributor count + self.round_contributor_count.write(round_id, 0); } fn is_member(self: @ComponentState, address: ContractAddress) -> bool { @@ -177,8 +314,14 @@ pub mod contribution_component { fn check_missed_contributions(ref self: ComponentState, round_id: u256) { let round = self.rounds.read(round_id); - assert(round.status == RoundStatus::Active, 'Round is not active'); - assert(get_block_timestamp() > round.deadline, 'Round deadline not passed'); + assert(round.status == RoundStatus::Active, ContributionErrors::ROUND_NOT_ACTIVE); + + let current_time = get_block_timestamp(); + let grace_period = self.grace_period_hours.read() * 3600; + assert( + current_time > round.deadline + grace_period, + ContributionErrors::ROUND_DEADLINE_NOT_PASSED, + ); // Check all members for missed contributions let all_members = self.get_all_members(); @@ -187,7 +330,14 @@ pub mod contribution_component { let member = *all_members[i]; let contribution = self.member_contributions.read((round_id, member)); if contribution.amount == 0 { - self.emit(ContributionMissed { round_id, member: member }); + self + .emit( + Event::ContributionMissed( + ContributionMissed { + round_id, member: member, timestamp: current_time, + }, + ), + ); } i += 1; } @@ -214,9 +364,9 @@ pub mod contribution_component { self.is_owner(); // Validate address is not zero - assert(!address.is_zero(), 'Invalid address'); + assert(!address.is_zero(), ContributionErrors::INVALID_ADDRESS); - assert(!self.is_member(address), 'Already a member'); + assert(!self.is_member(address), ContributionErrors::ALREADY_MEMBER); self.members.write(address, true); let count = self.member_count.read(); self.member_by_index.write(count, address); @@ -225,14 +375,31 @@ pub mod contribution_component { self.member_index_map.write(address, count); self.member_count.write(count + 1); - self.emit(MemberAdded { member: address }); + + // Initialize member statistics + self.member_contribution_count.write(address, 0); + self.member_last_contribution.write(address, 0); + + self + .emit( + Event::MemberAdded( + MemberAdded { + member: address, + added_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); } fn disburse_round_contribution(ref self: ComponentState, round_id: u256) { self.is_owner(); let round = self.rounds.read(round_id); - assert(round.status == RoundStatus::Completed, 'Round not completed'); + assert(round.status == RoundStatus::Completed, ContributionErrors::ROUND_NOT_COMPLETED); + + let current_time = get_block_timestamp(); + let contributor_count = self.round_contributor_count.read(round_id); // Token transfer to recipient let erc20_address = self.erc20_address.read(); @@ -243,7 +410,11 @@ pub mod contribution_component { .emit( Event::RoundDisbursed( RoundDisbursed { - round_id, recipient: round.recipient, amount: round.total_contributions, + round_id, + recipient: round.recipient, + amount: round.total_contributions, + contributor_count, + timestamp: current_time, }, ), ); @@ -252,7 +423,7 @@ pub mod contribution_component { fn remove_member(ref self: ComponentState, address: ContractAddress) { self.is_owner(); - assert(self.is_member(address), 'Not a member'); + assert(self.is_member(address), ContributionErrors::NOT_MEMBER); // Remove from members mapping self.members.write(address, false); @@ -273,7 +444,16 @@ pub mod contribution_component { self.member_count.write(last_index); self.member_index_map.write(address, 0); - self.emit(MemberRemoved { member: address }); + self + .emit( + Event::MemberRemoved( + MemberRemoved { + member: address, + removed_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); } fn get_round_details( @@ -298,12 +478,161 @@ pub mod contribution_component { let old_amount = self.required_contribution.read(); self.required_contribution.write(amount); - self.emit(RequiredContributionUpdated { old_amount, new_amount: amount }); + self + .emit( + Event::RequiredContributionUpdated( + RequiredContributionUpdated { + old_amount, + new_amount: amount, + updated_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); } fn get_required_contribution(self: @ComponentState) -> u256 { self.required_contribution.read() } + + // Enhanced functions + fn get_member_contribution_history( + self: @ComponentState, member: ContractAddress, limit: u32, offset: u32, + ) -> Array { + let mut contributions = ArrayTrait::new(); + let total_count = self.member_contribution_count.read(member); + + let mut i = offset; + let mut count = 0; + + while i < total_count && count < limit { + let round_id = self.member_contribution_history.read((member, i)); + if round_id > 0 { + let contribution = self.member_contributions.read((round_id, member)); + contributions.append(contribution); + count += 1; + } + i += 1; + } + + contributions + } + + fn get_round_statistics( + self: @ComponentState, round_id: u256, + ) -> (u256, u32, u32) { + let round = self.rounds.read(round_id); + let contributor_count = self.round_contributor_count.read(round_id); + let member_count = self.member_count.read(); + + (round.total_contributions, contributor_count, member_count) + } + + fn validate_contribution_eligibility( + self: @ComponentState, member: ContractAddress, round_id: u256, + ) -> bool { + // Check if member exists and is active + if !self.is_member(member) { + return false; + } + + // Check if round is active + let round = self.rounds.read(round_id); + if round.status != RoundStatus::Active { + return false; + } + + // Check if member already contributed + let contribution = self.member_contributions.read((round_id, member)); + if contribution.amount > 0 { + return false; + } + + // Check if deadline hasn't passed (including grace period) + let current_time = get_block_timestamp(); + let grace_period = self.grace_period_hours.read() * 3600; + if current_time > round.deadline + grace_period { + return false; + } + + true + } + + fn get_next_recipient(ref self: ComponentState) -> ContractAddress { + let member_count = self.member_count.read(); + if member_count == 0 { + return 0.try_into().unwrap(); + } + + // Start scanning from the next index after the current rotation index + let current_index = self.current_rotation_index.read(); + let start_index = (current_index + 1) % member_count; + + // Scan up to member_count entries to find the first active member + let mut scanned = 0_u32; + while scanned < member_count { + let candidate_index = (start_index + scanned) % member_count; + let candidate = self.member_by_index.read(candidate_index); + if self.is_member(candidate) { + // Advance rotation to the found active member and return it + self.current_rotation_index.write(candidate_index); + return candidate; + } + scanned += 1_u32; + } + + // If no active member found, return zero address + 0.try_into().unwrap() + } + + fn advance_round_rotation(ref self: ComponentState) { + self.is_owner(); + + let member_count = self.member_count.read(); + if member_count == 0 { + return; + } + + let current_index = self.current_rotation_index.read(); + let start_index = (current_index + 1) % member_count; + + // Scan up to member_count entries to find the first active member + let mut scanned = 0_u32; + let mut found = false; + let mut found_index = current_index; // default to current if none found + let mut next_recipient: ContractAddress = 0.try_into().unwrap(); + + while scanned < member_count { + let candidate_index = (start_index + scanned) % member_count; + let candidate = self.member_by_index.read(candidate_index); + if self.is_member(candidate) { + found = true; + found_index = candidate_index; + next_recipient = candidate; + break; + } + scanned += 1_u32; + } + + // If no active member is found, do not change the index + if !found { + return; + } + + self.current_rotation_index.write(found_index); + + self + .emit( + Event::RoundRotationAdvanced( + RoundRotationAdvanced { + old_index: current_index, + new_index: found_index, + next_recipient, + timestamp: get_block_timestamp(), + }, + ), + ); + } } #[generate_trait] @@ -315,12 +644,13 @@ pub mod contribution_component { > of InternalTrait { fn initializer(ref self: ComponentState, token_address: ContractAddress) { self.erc20_address.write(token_address); + self.grace_period_hours.write(24); // Default 24 hours grace period } fn is_owner(self: @ComponentState) { let owner_comp = get_dep_component!(self, Owner); let owner = owner_comp.owner(); - assert(owner == get_caller_address(), 'Caller is not the owner'); + assert(owner == get_caller_address(), ContributionErrors::NOT_OWNER); } } } diff --git a/src/component/emergency.cairo b/src/component/emergency.cairo new file mode 100644 index 0000000..827b96b --- /dev/null +++ b/src/component/emergency.cairo @@ -0,0 +1,199 @@ +use starknet::ContractAddress; + +#[derive(Copy, Drop, Serde, starknet::Store)] +pub struct EmergencyConfig { + pub emergency_cooldown: u64, + pub required_approvals: u8, +} + + +#[starknet::interface] +pub trait IEmergency { + // Configuration and query functions (simple operations) + fn set_config(ref self: TContractState, cfg: EmergencyConfig); + fn get_config(self: @TContractState) -> EmergencyConfig; + fn get_pause_reason(self: @TContractState) -> felt252; + fn get_pause_timestamp(self: @TContractState) -> u64; + fn is_paused(self: @TContractState) -> bool; + fn is_banned(self: @TContractState, member: ContractAddress) -> bool; + + // Utility functions (simple operations) + fn assert_paused(self: @TContractState); + fn assert_not_paused(self: @TContractState); +} + + +#[starknet::component] +pub mod emergency_component { + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use starkremit_contract::base::errors::EmergencyComponentErrors; + use super::EmergencyConfig; + + + #[storage] + pub struct Storage { + is_paused: bool, + pause_reason: felt252, + pause_timestamp: u64, + emergency_admin: ContractAddress, + banned_members: Map, + config: EmergencyConfig, + } + + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + PauseMetaSet: PauseMetaSet, + MemberBanned: MemberBanned, + MemberUnbanned: MemberUnbanned, + Paused: Paused, + Unpaused: Unpaused, + Initialized: Initialized, + } + + #[derive(Drop, starknet::Event)] + pub struct PauseMetaSet { + reason: felt252, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MemberBanned { + member: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct MemberUnbanned { + member: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct Paused { + reason: felt252, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct Unpaused { + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct Initialized { + admin: ContractAddress, + } + + + #[embeddable_as(Emergency)] + impl EmergencyImpl< + TContractState, +HasComponent, + > of super::IEmergency> { + fn get_pause_reason(self: @ComponentState) -> felt252 { + self.pause_reason.read() + } + + fn get_pause_timestamp(self: @ComponentState) -> u64 { + self.pause_timestamp.read() + } + + + fn is_banned(self: @ComponentState, member: ContractAddress) -> bool { + self.banned_members.read(member) + } + + fn set_config(ref self: ComponentState, cfg: EmergencyConfig) { + self._assert_admin(); + assert(!self.is_paused.read(), EmergencyComponentErrors::CONTRACT_PAUSED); + self.config.write(cfg); + } + + fn get_config(self: @ComponentState) -> EmergencyConfig { + self.config.read() + } + + fn is_paused(self: @ComponentState) -> bool { + self.is_paused.read() + } + + fn assert_paused(self: @ComponentState) { + assert(self.is_paused.read(), EmergencyComponentErrors::CONTRACT_NOT_PAUSED); + } + + fn assert_not_paused(self: @ComponentState) { + assert(!self.is_paused.read(), EmergencyComponentErrors::CONTRACT_PAUSED); + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.emergency_admin.write(admin); + self.is_paused.write(false); // Component starts unpaused by default + // Initialize config to default values + self.config.write(EmergencyConfig { emergency_cooldown: 0, required_approvals: 0 }); + self.emit(Event::Initialized(Initialized { admin })); + } + + // Internal function to assert that the caller is the emergency admin. + fn _assert_admin(self: @ComponentState) { + let admin: ContractAddress = self.emergency_admin.read(); + let caller: ContractAddress = get_caller_address(); + assert(caller == admin, EmergencyComponentErrors::NOT_ADMIN); + } + + // Internal function to toggle the pause state. + fn _toggle_pause(ref self: ComponentState, paused: bool) { + self.is_paused.write(paused); + if paused { + self.pause_timestamp.write(get_block_timestamp()); + } else { + self.pause_reason.write(0); // Clear reason on unpause + self.pause_timestamp.write(0); // Clear timestamp on unpause + } + } + + // Complex operations that will be called by the main contract + fn _pause(ref self: ComponentState) { + assert(!self.is_paused.read(), EmergencyComponentErrors::ALREADY_PAUSED); + self._toggle_pause(true); + } + + fn _unpause(ref self: ComponentState) { + assert(self.is_paused.read(), EmergencyComponentErrors::CONTRACT_NOT_PAUSED); + self._toggle_pause(false); + } + + fn _pause_with_metadata(ref self: ComponentState, reason: felt252) { + assert(!self.is_paused.read(), EmergencyComponentErrors::ALREADY_PAUSED); + self.pause_reason.write(reason); + self._toggle_pause(true); + } + + fn _unpause_with_metadata_clear(ref self: ComponentState) { + assert(self.is_paused.read(), EmergencyComponentErrors::CONTRACT_NOT_PAUSED); + self.pause_reason.write(0); + self.pause_timestamp.write(0); + self._toggle_pause(false); + } + + fn _set_pause_meta(ref self: ComponentState, reason: felt252) { + assert(self.is_paused.read(), EmergencyComponentErrors::CONTRACT_NOT_PAUSED); + self.pause_reason.write(reason); + self.pause_timestamp.write(get_block_timestamp()); + } + + fn _set_ban( + ref self: ComponentState, member: ContractAddress, banned: bool, + ) { + assert(!self.is_paused.read(), EmergencyComponentErrors::CONTRACT_PAUSED); + self.banned_members.write(member, banned); + } + } +} diff --git a/src/component/member_profile.cairo b/src/component/member_profile.cairo new file mode 100644 index 0000000..253720f --- /dev/null +++ b/src/component/member_profile.cairo @@ -0,0 +1,379 @@ +use starknet::ContractAddress; +use starkremit_contract::base::types::MemberProfileData; + +// Trait that the main contract must implement to provide data access +pub trait IMainContractData { + fn get_member_status(self: @TContractState, member: ContractAddress) -> bool; + fn get_member_count(self: @TContractState) -> u32; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; +} + +#[starknet::interface] +pub trait IMemberProfile { + fn create_member_profile(ref self: TContractState, member: ContractAddress); + fn update_reliability_rating(ref self: TContractState, member: ContractAddress, new_rating: u8); + fn get_member_profile(self: @TContractState, member: ContractAddress) -> MemberProfileData; + fn add_to_waitlist(ref self: TContractState, member: ContractAddress); + fn remove_from_waitlist(ref self: TContractState, member: ContractAddress) -> bool; + fn get_waitlist_position(self: @TContractState, member: ContractAddress) -> u32; + fn update_communication_preferences( + ref self: TContractState, member: ContractAddress, preferences: felt252, + ); + fn send_member_message(ref self: TContractState, member: ContractAddress, message: felt252); +} + +#[starknet::component] +pub mod member_profile_component { + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; + use starkremit_contract::base::errors::MemberProfileComponentErrors; + use super::*; + + + #[storage] + #[allow(starknet::invalid_storage_member_types)] + pub struct Storage { + member_profiles: Map, + waitlist: Map, // Index -> Address + waitlist_length: u32, + total_members: u32, + admin: ContractAddress, + member_messages: Map<(ContractAddress, u32), felt252>, // (member, message_index) -> message + message_counts: Map // member -> message_count + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + ProfileCreated: ProfileCreated, + ProfileUpdated: ProfileUpdated, + ReliabilityRatingUpdated: ReliabilityRatingUpdated, + MemberAddedToWaitlist: MemberAddedToWaitlist, + MemberRemovedFromWaitlist: MemberRemovedFromWaitlist, + CommunicationSent: CommunicationSent, + ContributionRecorded: ContributionRecorded, + MissedContributionRecorded: MissedContributionRecorded, + PaymentMethodUpdated: PaymentMethodUpdated, + } + + #[derive(Drop, starknet::Event)] + pub struct ProfileCreated { + member: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ProfileUpdated { + member: ContractAddress, + updated_by: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ReliabilityRatingUpdated { + member: ContractAddress, + old_rating: u8, + new_rating: u8, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MemberAddedToWaitlist { + member: ContractAddress, + position: u32, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MemberRemovedFromWaitlist { + member: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct CommunicationSent { + message_hash: felt252, + recipients_count: u32, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ContributionRecorded { + member: ContractAddress, + amount: u256, + round_id: u256, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct MissedContributionRecorded { + member: ContractAddress, + round_id: u256, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct PaymentMethodUpdated { + member: ContractAddress, + old_method: felt252, + new_method: felt252, + timestamp: u64, + } + + #[embeddable_as(MemberProfile)] + pub impl MemberProfileImpl< + TContractState, +HasComponent, +IMainContractData, + > of IMemberProfile> { + fn create_member_profile( + ref self: ComponentState, member: ContractAddress, + ) { + // Check if profile already exists + let existing_profile = self.member_profiles.read(member); + assert( + existing_profile.join_date == 0, + MemberProfileComponentErrors::PROFILE_ALREADY_EXISTS, + ); + + let current_time = get_block_timestamp(); + let new_profile = MemberProfileData { + join_date: current_time, + total_contributions: 0, + missed_contributions: 0, + credit_score: 50, // Start with neutral score + last_recipient_round: 0, + reliability_rating: 50, // Start with neutral rating + preferred_payment_method: 'DEFAULT', + communication_preferences: 'ALL', + is_on_waitlist: false, + waitlist_position: 0, + last_message_timestamp: 0, + }; + + self.member_profiles.write(member, new_profile); + let total = self.total_members.read(); + self.total_members.write(total + 1); + + self.emit(Event::ProfileCreated(ProfileCreated { member, timestamp: current_time })); + } + + fn update_reliability_rating( + ref self: ComponentState, member: ContractAddress, new_rating: u8, + ) { + self._assert_admin(); + assert(new_rating <= 100, MemberProfileComponentErrors::INVALID_RATING); + + let mut profile = self.member_profiles.read(member); + assert(profile.join_date != 0, MemberProfileComponentErrors::PROFILE_NOT_FOUND); + + let old_rating = profile.reliability_rating; + profile.reliability_rating = new_rating; + self.member_profiles.write(member, profile); + + self + .emit( + Event::ReliabilityRatingUpdated( + ReliabilityRatingUpdated { + member, old_rating, new_rating, timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn get_member_profile( + self: @ComponentState, member: ContractAddress, + ) -> MemberProfileData { + let profile = self.member_profiles.read(member); + assert(profile.join_date != 0, MemberProfileComponentErrors::PROFILE_NOT_FOUND); + profile + } + + fn add_to_waitlist(ref self: ComponentState, member: ContractAddress) { + let current_length = self.waitlist_length.read(); + self.waitlist.write(current_length, member); + self.waitlist_length.write(current_length + 1); + + // Update member profile + let mut profile = self.member_profiles.read(member); + profile.is_on_waitlist = true; + profile.waitlist_position = current_length + 1; + self.member_profiles.write(member, profile); + + self + .emit( + Event::MemberAddedToWaitlist( + MemberAddedToWaitlist { + member, position: current_length + 1, timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn remove_from_waitlist( + ref self: ComponentState, member: ContractAddress, + ) -> bool { + let waitlist_length = self.waitlist_length.read(); + let mut found = false; + let mut i = 0; + + // Find member in waitlist + loop { + if i >= waitlist_length { + break; + } + + if self.waitlist.read(i) == member { + found = true; + // Shift remaining members + let mut j = i; + loop { + if j + 1 >= waitlist_length { + break; + } + let next_member = self.waitlist.read(j + 1); + self.waitlist.write(j, next_member); + j += 1; + } + // Update waitlist_position for shifted members' profiles + let mut k = i; + loop { + if k + 1 > waitlist_length { + break; + } + // Only indices up to waitlist_length - 2 are valid after shift + if k >= waitlist_length - 1 { + break; + } + let shifted_member = self.waitlist.read(k); + let mut shifted_profile = self.member_profiles.read(shifted_member); + shifted_profile.waitlist_position = k + 1; + self.member_profiles.write(shifted_member, shifted_profile); + k += 1; + } + break; + } + i += 1; + } + + if found { + self.waitlist_length.write(waitlist_length - 1); + + // Update member profile + let mut profile = self.member_profiles.read(member); + profile.is_on_waitlist = false; + profile.waitlist_position = 0; + self.member_profiles.write(member, profile); + + self + .emit( + Event::MemberRemovedFromWaitlist( + MemberRemovedFromWaitlist { member, timestamp: get_block_timestamp() }, + ), + ); + } + + found + } + + fn get_waitlist_position( + self: @ComponentState, member: ContractAddress, + ) -> u32 { + let profile = self.member_profiles.read(member); + if profile.is_on_waitlist { + profile.waitlist_position + } else { + 0 + } + } + + fn update_communication_preferences( + ref self: ComponentState, member: ContractAddress, preferences: felt252, + ) { + let mut profile = self.member_profiles.read(member); + assert(profile.join_date != 0, MemberProfileComponentErrors::PROFILE_NOT_FOUND); + + let old_preferences = profile.communication_preferences; + profile.communication_preferences = preferences; + self.member_profiles.write(member, profile); + + self + .emit( + Event::CommunicationSent( + CommunicationSent { + message_hash: preferences, + recipients_count: 1, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn send_member_message( + ref self: ComponentState, member: ContractAddress, message: felt252, + ) { + let mut profile = self.member_profiles.read(member); + assert(profile.join_date != 0, MemberProfileComponentErrors::PROFILE_NOT_FOUND); + + // Store message + let message_count = self.message_counts.read(member); + self.member_messages.write((member, message_count), message); + self.message_counts.write(member, message_count + 1); + + // Update last message timestamp + profile.last_message_timestamp = get_block_timestamp(); + self.member_profiles.write(member, profile); + + self + .emit( + Event::CommunicationSent( + CommunicationSent { + message_hash: message, + recipients_count: 1, + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, +IMainContractData, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.admin.write(admin); + self.total_members.write(0); + self.waitlist_length.write(0); + } + + fn _assert_admin(self: @ComponentState) { + let admin = self.admin.read(); + assert(get_caller_address() == admin, MemberProfileComponentErrors::NOT_ADMIN); + } + + fn _calculate_credit_score( + self: @ComponentState, profile: @MemberProfileData, + ) -> u8 { + // Simple credit score calculation based on contributions vs missed + let total_rounds: u256 = *profile.total_contributions + + (*profile.missed_contributions).into(); + if total_rounds == 0 { + return 50; // Neutral score for new members + } + + let success_rate: u256 = (*profile.total_contributions * 100_u256) / total_rounds; + success_rate.try_into().unwrap_or(50) + } + + fn _update_contribution_stats( + ref self: ComponentState, member: ContractAddress, amount: u256, + ) { + let mut profile = self.member_profiles.read(member); + profile.total_contributions += amount; + profile.credit_score = self._calculate_credit_score(@profile); + self.member_profiles.write(member, profile); + } + } +} diff --git a/src/component/payment_flexibility.cairo b/src/component/payment_flexibility.cairo new file mode 100644 index 0000000..57363cd --- /dev/null +++ b/src/component/payment_flexibility.cairo @@ -0,0 +1,628 @@ +use starknet::ContractAddress; +use starkremit_contract::base::types::{ + AutoPaymentSetup, EarlyPaymentInfo, PaymentConfig, PaymentFrequency, PaymentRecord, + PaymentStatus, RoundData, RoundStatus, +}; + +// Trait that the main contract must implement to provide data access +pub trait IMainContractData { + fn get_round_data(self: @TContractState, round_id: u256) -> RoundData; + fn get_member_status(self: @TContractState, member: ContractAddress) -> bool; + fn get_member_count(self: @TContractState) -> u32; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; +} + +#[starknet::interface] +pub trait IPaymentFlexibility { + // Configuration and query functions (simple operations) + fn get_payment_config(self: @TContractState) -> PaymentConfig; + fn get_auto_payment_setup(self: @TContractState, member: ContractAddress) -> AutoPaymentSetup; + fn get_payment_status( + self: @TContractState, member: ContractAddress, round_id: u256, + ) -> PaymentStatus; + fn get_supported_tokens(self: @TContractState) -> Array; + fn is_token_supported(self: @TContractState, token: ContractAddress) -> bool; + + // Utility functions (simple operations) + fn get_grace_period_extension(self: @TContractState, member: ContractAddress) -> u64; + fn get_early_payment_discount(self: @TContractState, amount: u256) -> u256; +} + + +#[starknet::component] +pub mod payment_flexibility_component { + use core::array::ArrayTrait; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; + use starkremit_contract::base::errors::PaymentFlexibilityErrors; + use super::*; + + const SECONDS_PER_HOUR: u64 = 3600; + const SECONDS_PER_DAY: u64 = 86400; + const BASIS_POINTS: u256 = 10000; + + #[storage] + pub struct Storage { + payment_config: PaymentConfig, + auto_payment_setups: Map, + payment_records: Map< + (ContractAddress, u256), PaymentRecord, + >, // (member, round_id) -> record + supported_tokens: Map, // Index -> Token + supported_tokens_count: u32, + early_payments: Map, // round_id -> early payment info + grace_period_extensions: Map, // member -> extension hours + admin: ContractAddress, + last_auto_payment_processing: u64, + auto_payment_interval: u64 // How often to process auto-payments + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + AutoPaymentSetup: AutoPaymentSetupEvent, + EarlyPaymentProcessed: EarlyPaymentProcessed, + GracePeriodExtended: GracePeriodExtended, + TokenValueConverted: TokenValueConverted, + PaymentStatusUpdated: PaymentStatusUpdated, + AutoPaymentExecuted: AutoPaymentExecuted, + BulkAutoPaymentsProcessed: BulkAutoPaymentsProcessed, + SupportedTokenAdded: SupportedTokenAdded, + SupportedTokenRemoved: SupportedTokenRemoved, + PaymentConfigUpdated: PaymentConfigUpdated, + GracePeriodUsed: GracePeriodUsed, + } + + #[derive(Drop, starknet::Event)] + pub struct AutoPaymentSetupEvent { + member: ContractAddress, + token: ContractAddress, + amount: u256, + frequency: PaymentFrequency, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct EarlyPaymentProcessed { + member: ContractAddress, + round_id: u256, + original_amount: u256, + discount_amount: u256, + final_amount: u256, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct GracePeriodExtended { + member: ContractAddress, + extension_hours: u64, + new_deadline: u64, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct TokenValueConverted { + from_token: ContractAddress, + to_token: ContractAddress, + input_amount: u256, + output_amount: u256, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct PaymentStatusUpdated { + member: ContractAddress, + round_id: u256, + old_status: PaymentStatus, + new_status: PaymentStatus, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct AutoPaymentExecuted { + member: ContractAddress, + amount: u256, + token: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct BulkAutoPaymentsProcessed { + processed_count: u32, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct SupportedTokenAdded { + token: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct SupportedTokenRemoved { + token: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct PaymentConfigUpdated { + admin: ContractAddress, + timestamp: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct GracePeriodUsed { + member: ContractAddress, + round_id: u256, + extension_hours: u64, + timestamp: u64, + } + + #[embeddable_as(PaymentFlexibility)] + pub impl PaymentFlexibilityImpl< + TContractState, +HasComponent, +IMainContractData, + > of super::IPaymentFlexibility> { + fn get_payment_config(self: @ComponentState) -> PaymentConfig { + self.payment_config.read() + } + + fn get_auto_payment_setup( + self: @ComponentState, member: ContractAddress, + ) -> AutoPaymentSetup { + self.auto_payment_setups.read(member) + } + + fn get_payment_status( + self: @ComponentState, member: ContractAddress, round_id: u256, + ) -> PaymentStatus { + self._calculate_payment_status(member, round_id) + } + + fn get_supported_tokens(self: @ComponentState) -> Array { + let mut tokens = ArrayTrait::new(); + let count = self.supported_tokens_count.read(); + let mut i = 0; + while i < count { + let token = self.supported_tokens.read(i); + tokens.append(token); + i += 1; + } + tokens + } + + fn is_token_supported( + self: @ComponentState, token: ContractAddress, + ) -> bool { + self._is_token_supported(token) + } + + fn get_grace_period_extension( + self: @ComponentState, member: ContractAddress, + ) -> u64 { + self.grace_period_extensions.read(member) + } + + fn get_early_payment_discount(self: @ComponentState, amount: u256) -> u256 { + let config = self.payment_config.read(); + (amount * config.early_payment_discount_basis_points) / BASIS_POINTS + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, +IMainContractData, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.admin.write(admin); + + // Set default payment configuration + let default_config = PaymentConfig { + grace_period_hours: 48, // 48 hours default grace period + early_payment_discount_basis_points: 500, // 5% discount for early payments + auto_payment_enabled: true, + usd_oracle_address: 0.try_into().unwrap(), // No oracle by default + max_grace_period_extension: 168, // Maximum 1 week extension + min_early_payment_days: 7 // Minimum 7 days before deadline for early payment + }; + self.payment_config.write(default_config); + + // Initialize auto-payment processing + self.last_auto_payment_processing.write(get_block_timestamp()); + self.auto_payment_interval.write(3600); // Process every hour + + // Initialize supported tokens count + self.supported_tokens_count.write(0); + } + + // Internal function to assert that the caller is the admin + fn _assert_admin(self: @ComponentState) { + let admin: ContractAddress = self.admin.read(); + let caller: ContractAddress = get_caller_address(); + assert(caller == admin, PaymentFlexibilityErrors::NOT_ADMIN); + } + + // Complex operations that will be called by the main contract + fn _setup_auto_payment( + ref self: ComponentState, + member: ContractAddress, + token: ContractAddress, + amount: u256, + frequency: PaymentFrequency, + ) { + let config = self.payment_config.read(); + assert(config.auto_payment_enabled, PaymentFlexibilityErrors::AUTO_PAYMENT_DISABLED); + assert(amount > 0, PaymentFlexibilityErrors::INVALID_AMOUNT); + assert(self._is_token_supported(token), PaymentFlexibilityErrors::INVALID_TOKEN); + + // Check if member already has auto-payment setup + let existing_setup = self.auto_payment_setups.read(member); + assert(!existing_setup.is_active, PaymentFlexibilityErrors::AUTO_PAYMENT_ACTIVE); + + // Calculate next payment date based on frequency + let next_payment_date = self._calculate_next_payment_date(frequency); + + // Create auto-payment setup + let auto_payment = AutoPaymentSetup { + member, + token, + amount, + frequency, + next_payment_date, + is_active: true, + created_at: get_block_timestamp(), + last_payment_date: 0, + }; + + self.auto_payment_setups.write(member, auto_payment); + + self + .emit( + Event::AutoPaymentSetup( + AutoPaymentSetupEvent { + member, token, amount, frequency, timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _process_early_payment( + ref self: ComponentState, + member: ContractAddress, + round_id: u256, + amount: u256, + token: ContractAddress, + ) -> (u256, u256) { + let config = self.payment_config.read(); + let contract_state = self.get_contract(); + let round = contract_state.get_round_data(round_id); + + // Validate round is active + assert(round.status == RoundStatus::Active, PaymentFlexibilityErrors::ROUND_NOT_FOUND); + // Validate token is non-zero (retain actual token info) + assert(token != 0.try_into().unwrap(), PaymentFlexibilityErrors::INVALID_TOKEN); + + // Check if payment is early enough to qualify for discount + let current_time = get_block_timestamp(); + let days_until_deadline = (round.deadline - current_time) / SECONDS_PER_DAY; + assert( + days_until_deadline >= config.min_early_payment_days, + PaymentFlexibilityErrors::INVALID_AMOUNT, + ); + + // Calculate early payment discount + let discount_amount = self._calculate_early_payment_discount(amount); + let final_amount = amount - discount_amount; + + // Store early payment info + let early_payment = EarlyPaymentInfo { + member, + round_id, + original_amount: amount, + discount_amount, + final_amount, + payment_date: current_time, + }; + self.early_payments.write(round_id, early_payment); + + // Update payment record + let payment_record = PaymentRecord { + member, + round_id, + amount: final_amount, + token, + payment_date: current_time, + status: PaymentStatus::Early, + is_early_payment: true, + discount_applied: discount_amount, + grace_period_used: 0, + }; + self.payment_records.write((member, round_id), payment_record); + + self + .emit( + Event::EarlyPaymentProcessed( + EarlyPaymentProcessed { + member, + round_id, + original_amount: amount, + discount_amount, + final_amount, + timestamp: current_time, + }, + ), + ); + + (final_amount, discount_amount) + } + + fn _extend_grace_period( + ref self: ComponentState, member: ContractAddress, extension_hours: u64, + ) { + self._assert_admin(); + + let config = self.payment_config.read(); + assert(extension_hours > 0, PaymentFlexibilityErrors::INVALID_AMOUNT); + assert( + extension_hours <= config.max_grace_period_extension, + PaymentFlexibilityErrors::INVALID_AMOUNT, + ); + + // Get current grace period extension + let current_extension = self.grace_period_extensions.read(member); + let new_extension = current_extension + extension_hours; + + // Update grace period extension + self.grace_period_extensions.write(member, new_extension); + + self + .emit( + Event::GracePeriodExtended( + GracePeriodExtended { + member, + extension_hours, + new_deadline: get_block_timestamp() + + (new_extension * SECONDS_PER_HOUR), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _process_auto_payments(ref self: ComponentState) { + let config = self.payment_config.read(); + if !config.auto_payment_enabled { + return; + } + + let current_time = get_block_timestamp(); + let last_processing = self.last_auto_payment_processing.read(); + let interval = self.auto_payment_interval.read(); + + // Check if it's time to process auto-payments + if current_time < last_processing + interval { + return; + } + + let mut processed_count = 0; + let member_count = self._get_member_count(); + + // Process auto-payments for all members + let mut i = 0; + while i < member_count { + let member = self._get_member_by_index(i); + let auto_setup = self.auto_payment_setups.read(member); + + if auto_setup.is_active && current_time >= auto_setup.next_payment_date { + // Execute auto-payment + self._execute_auto_payment(member, auto_setup); + processed_count += 1; + } + i += 1; + } + + // Update last processing timestamp + self.last_auto_payment_processing.write(current_time); + + if processed_count > 0_u32 { + self + .emit( + Event::BulkAutoPaymentsProcessed( + BulkAutoPaymentsProcessed { processed_count, timestamp: current_time }, + ), + ); + } + } + + fn _add_supported_token(ref self: ComponentState, token: ContractAddress) { + self._assert_admin(); + + // Check if token is already supported + assert(!self._is_token_supported(token), PaymentFlexibilityErrors::INVALID_TOKEN); + + let count = self.supported_tokens_count.read(); + self.supported_tokens.write(count, token); + self.supported_tokens_count.write(count + 1); + + self + .emit( + Event::SupportedTokenAdded( + SupportedTokenAdded { token, timestamp: get_block_timestamp() }, + ), + ); + } + + fn _remove_supported_token( + ref self: ComponentState, token: ContractAddress, + ) { + self._assert_admin(); + + // Check if token is supported + assert(self._is_token_supported(token), PaymentFlexibilityErrors::INVALID_TOKEN); + + // Find token index, swap with last element, and decrement count to keep array compact + let count = self.supported_tokens_count.read(); + let mut i = 0; + while i < count { + let stored_token = self.supported_tokens.read(i); + if stored_token == token { + let last_index = count - 1_u32; + if i != last_index { + let last_token = self.supported_tokens.read(last_index); + self.supported_tokens.write(i, last_token); + } + // Clear last slot and update count + self.supported_tokens.write(last_index, 0.try_into().unwrap()); + self.supported_tokens_count.write(last_index); + break; + } + i += 1; + } + + self + .emit( + Event::SupportedTokenRemoved( + SupportedTokenRemoved { token, timestamp: get_block_timestamp() }, + ), + ); + } + + fn _update_payment_config( + ref self: ComponentState, new_config: PaymentConfig, + ) { + self._assert_admin(); + + let old_config = self.payment_config.read(); + self.payment_config.write(new_config); + + self + .emit( + Event::PaymentConfigUpdated( + PaymentConfigUpdated { + admin: get_caller_address(), timestamp: get_block_timestamp(), + }, + ), + ); + } + + // Helper functions + fn _calculate_payment_status( + self: @ComponentState, member: ContractAddress, round_id: u256, + ) -> PaymentStatus { + let contract_state = self.get_contract(); + let round = contract_state.get_round_data(round_id); + let payment_record = self.payment_records.read((member, round_id)); + let current_time = get_block_timestamp(); + let config = self.payment_config.read(); + + if payment_record.amount == 0 { + // No payment made + let grace_period_end = round.deadline + + (config.grace_period_hours * SECONDS_PER_HOUR); + let member_extension = self.grace_period_extensions.read(member); + let extended_deadline = grace_period_end + (member_extension * SECONDS_PER_HOUR); + + if current_time > extended_deadline { + return PaymentStatus::Missed; + } else if current_time > round.deadline { + return PaymentStatus::Late; + } else { + return PaymentStatus::Pending; + } + } else { + // Payment made + if payment_record.is_early_payment { + return PaymentStatus::Early; + } else if current_time <= round.deadline { + return PaymentStatus::Paid; + } else if current_time <= round.deadline + + (config.grace_period_hours * SECONDS_PER_HOUR) { + return PaymentStatus::Late; + } else { + return PaymentStatus::PaidAfterGrace; // Payment made after grace period + } + } + } + + fn _calculate_next_payment_date( + self: @ComponentState, frequency: PaymentFrequency, + ) -> u64 { + let current_time = get_block_timestamp(); + + match frequency { + PaymentFrequency::Once => current_time, + PaymentFrequency::Daily => current_time + SECONDS_PER_DAY, + PaymentFrequency::Weekly => current_time + (SECONDS_PER_DAY * 7), + PaymentFrequency::Monthly => current_time + (SECONDS_PER_DAY * 30), + } + } + + fn _calculate_early_payment_discount( + self: @ComponentState, amount: u256, + ) -> u256 { + let config = self.payment_config.read(); + (amount * config.early_payment_discount_basis_points) / BASIS_POINTS + } + + fn _is_token_supported( + self: @ComponentState, token: ContractAddress, + ) -> bool { + let count = self.supported_tokens_count.read(); + let mut i = 0; + while i < count { + let supported_token = self.supported_tokens.read(i); + if supported_token == token { + return true; + } + i += 1; + } + false + } + + fn _execute_auto_payment( + ref self: ComponentState, + member: ContractAddress, + mut auto_setup: AutoPaymentSetup, + ) { + // Execute the actual payment transfer from the member to this contract + let token_dispatcher = IERC20Dispatcher { contract_address: auto_setup.token }; + token_dispatcher.transfer_from(member, get_contract_address(), auto_setup.amount); + // Update last payment date + auto_setup.last_payment_date = get_block_timestamp(); + + // Calculate next payment date + auto_setup.next_payment_date = self._calculate_next_payment_date(auto_setup.frequency); + + // Save updated auto-payment setup + self.auto_payment_setups.write(member, auto_setup); + + // Emit auto-payment executed event + self + .emit( + Event::AutoPaymentExecuted( + AutoPaymentExecuted { + member, + amount: auto_setup.amount, + token: auto_setup.token, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn _get_member_count(self: @ComponentState) -> u32 { + let contract_state = self.get_contract(); + contract_state.get_member_count() + } + + fn _get_member_by_index( + self: @ComponentState, index: u32, + ) -> ContractAddress { + let contract_state = self.get_contract(); + contract_state.get_member_by_index(index) + } + } +} diff --git a/src/component/penalty.cairo b/src/component/penalty.cairo new file mode 100644 index 0000000..ecac371 --- /dev/null +++ b/src/component/penalty.cairo @@ -0,0 +1,486 @@ +use core::array::{Array, ArrayTrait}; +use core::serde::Serde; +use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; +use starkremit_contract::base::types::{ + DistributionData, MemberContribution, MemberPenaltyRecord, MemberShare, PenaltyConfig, + PenaltyEventRecord, PenaltyEventType, RoundData, RoundStatus, +}; + +// Trait that the main contract must implement to provide data access +pub trait IMainContractData { + fn get_member_contribution_data( + self: @TContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution; + fn get_round_data(self: @TContractState, round_id: u256) -> RoundData; + fn get_member_status(self: @TContractState, member: ContractAddress) -> bool; + fn get_member_count(self: @TContractState) -> u32; + fn get_round_ids(self: @TContractState) -> u256; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; +} + + +#[starknet::interface] +pub trait IPenalty { + fn set_penalty_config(ref self: TContractState, config: PenaltyConfig); + fn get_penalty_config(self: @TContractState) -> PenaltyConfig; + fn get_member_penalty_record( + self: @TContractState, member: ContractAddress, + ) -> MemberPenaltyRecord; + fn get_penalty_pool(self: @TContractState) -> u256; + // Distribution calculation function + fn calculate_distribution_data(self: @TContractState) -> DistributionData; + // Reset penalty pool after distribution + fn reset_penalty_pool(ref self: TContractState); + // History functions + fn get_penalty_history( + self: @TContractState, member: ContractAddress, limit: u32, offset: u32, + ) -> Array; +} + + +// Component events +#[derive(Drop, starknet::Event)] +pub struct LateFeeApplied { + pub member: ContractAddress, + pub round_id: u256, + pub fee_amount: u256, + pub contribution_amount: u256, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct StrikeAdded { + pub member: ContractAddress, + pub round_id: u256, + pub current_strikes: u32, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct StrikeRemoved { + pub member: ContractAddress, + pub removed_by: ContractAddress, + pub new_strikes: u32, + pub timestamp: u64, +} + + +#[derive(Drop, starknet::Event)] +pub struct PenaltyConfigUpdated { + pub old_config: PenaltyConfig, + pub new_config: PenaltyConfig, + pub updated_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct PenaltyPoolDistributed { + pub total_amount: u256, + pub recipient_count: u32, + pub distribution_type: felt252, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct GracePeriodExtended { + pub member: ContractAddress, + pub extension_hours: u64, + pub total_extension: u64, + pub extended_by: ContractAddress, + pub timestamp: u64, +} + +#[starknet::component] +pub mod penalty_component { + use core::array::ArrayTrait; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use starkremit_contract::base::errors::PenaltyComponentErrors; + use super::*; + + #[storage] + pub struct Storage { + penalty_config: PenaltyConfig, + member_penalties: Map, + penalty_pool: u256, + penalty_history: Map<(ContractAddress, u32), PenaltyEventRecord>, + penalty_history_count: Map, + grace_period_extensions: Map, + admin: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + LateFeeApplied: LateFeeApplied, + StrikeAdded: StrikeAdded, + StrikeRemoved: StrikeRemoved, + PenaltyConfigUpdated: PenaltyConfigUpdated, + PenaltyPoolDistributed: PenaltyPoolDistributed, + GracePeriodExtended: GracePeriodExtended, + } + + #[embeddable_as(Penalty)] + impl PenaltyImpl< + TContractState, +HasComponent, +IMainContractData, + > of super::IPenalty> { + fn set_penalty_config(ref self: ComponentState, config: PenaltyConfig) { + self._assert_admin(); + + let old_config = self.penalty_config.read(); + self.penalty_config.write(config); + + self + .emit( + Event::PenaltyConfigUpdated( + PenaltyConfigUpdated { + old_config, + new_config: config, + updated_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn get_penalty_config(self: @ComponentState) -> PenaltyConfig { + self.penalty_config.read() + } + + fn get_member_penalty_record( + self: @ComponentState, member: ContractAddress, + ) -> MemberPenaltyRecord { + self.member_penalties.read(member) + } + + fn get_penalty_pool(self: @ComponentState) -> u256 { + self.penalty_pool.read() + } + + fn reset_penalty_pool(ref self: ComponentState) { + self._reset_penalty_pool(); + } + + fn calculate_distribution_data(self: @ComponentState) -> DistributionData { + let contract_state = self.get_contract(); + let penalty_pool_amount = self.penalty_pool.read(); + + if penalty_pool_amount == 0 { + return DistributionData { + total_amount: 0, + member_shares: ArrayTrait::new(), + total_compliant_contributions: 0, + }; + } + + let mut total_compliant_contributions = 0; + let mut member_shares = ArrayTrait::new(); + + // Calculate total contributions from compliant members + let mut member_index = 0; + let total_members = contract_state.get_member_count(); + + while member_index < total_members { + let member_address = contract_state.get_member_by_index(member_index); + if contract_state.get_member_status(member_address) { + let penalty_record = self.member_penalties.read(member_address); + if !penalty_record.is_banned { + // Calculate member's total contribution across all rounds + let mut member_contribution = 0; + let mut round_id = 1; + while round_id <= contract_state.get_round_ids() { + let contribution = contract_state + .get_member_contribution_data(round_id, member_address); + member_contribution += contribution.amount; + round_id += 1; + } + + total_compliant_contributions += member_contribution; + + if member_contribution > 0 { + member_shares + .append( + MemberShare { + member: member_address, + share: 0, + contribution: member_contribution, + }, + ); + } + } + } + member_index += 1; + } + + // Compute final shares after total_compliant_contributions is known + let mut computed_member_shares = ArrayTrait::new(); + let shares_span = member_shares.span(); + for member_share in shares_span { + let mut computed_share: u256 = 0; + if total_compliant_contributions > 0 { + computed_share = (*member_share.contribution * penalty_pool_amount) + / total_compliant_contributions; + } + if computed_share > 0 { + computed_member_shares + .append( + MemberShare { + member: *member_share.member, + share: computed_share, + contribution: *member_share.contribution, + }, + ); + } + } + member_shares = computed_member_shares; + + let final_total_contributions: u256 = total_compliant_contributions; + DistributionData { + total_amount: penalty_pool_amount, + member_shares, + total_compliant_contributions: final_total_contributions, + } + } + + fn get_penalty_history( + self: @ComponentState, member: ContractAddress, limit: u32, offset: u32, + ) -> Array { + let mut history = ArrayTrait::new(); + let total_count = self.penalty_history_count.read(member); + + let mut i = offset; + let mut count = 0; + + while i < total_count && count < limit { + let event = self.penalty_history.read((member, i)); + history.append(event); + count += 1; + i += 1; + } + + history + } + } + + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent, +IMainContractData, + > of InternalTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress) { + self.admin.write(admin); + + // Set default penalty configuration + let default_config = PenaltyConfig { + late_fee_percentage: 250, // 2.5% in basis points + grace_period_hours: 48, // 48 hours + max_strikes: 2, // 2 strikes before ban + security_deposit_multiplier: 100000000000000000000, // 100 tokens + penalty_pool_enabled: true, + }; + self.penalty_config.write(default_config); + + // Initialize penalty pool + self.penalty_pool.write(0); + } + + fn _assert_admin(self: @ComponentState) { + let admin = self.admin.read(); + assert(get_caller_address() == admin, PenaltyComponentErrors::NOT_ADMIN); + } + + // Core penalty functions that need access to main contract data + fn apply_late_fee( + ref self: ComponentState, member: ContractAddress, round_id: u256, + ) { + // Get main contract state to access member contributions and rounds + let contract_state = self.get_contract(); + + // Get penalty configuration and round data + let penalty_config = self.penalty_config.read(); + let round = contract_state.get_round_data(round_id); + let member_ext: u64 = self.grace_period_extensions.read(member); + let total_grace_secs: u64 = penalty_config.grace_period_hours * 3600 + member_ext; + assert( + get_block_timestamp() > round.deadline + total_grace_secs, + PenaltyComponentErrors::NOT_LATE, + ); + + // If pool is disabled, do not collect late fees into pool + assert( + penalty_config.penalty_pool_enabled, PenaltyComponentErrors::PENALTY_POOL_DISABLED, + ); + + // Get member contribution from main contract storage via trait + let contribution = contract_state.get_member_contribution_data(round_id, member); + assert(contribution.amount > 0, PenaltyComponentErrors::NO_CONTRIBUTION_FOR_ROUND); + + // Calculate late fee + let late_fee = (contribution.amount * penalty_config.late_fee_percentage) / 10000; + + // Update penalty pool + self._update_penalty_pool(late_fee); + + // Update member penalty record + let mut penalty_record = self.member_penalties.read(member); + penalty_record.total_penalties_paid += late_fee; + penalty_record.last_penalty_date = get_block_timestamp(); + self.member_penalties.write(member, penalty_record); + + // Record penalty event + self._record_penalty_event(member, round_id, PenaltyEventType::LateFee, late_fee); + + // Emit component event + self + .emit( + Event::LateFeeApplied( + LateFeeApplied { + member, + round_id, + fee_amount: late_fee, + contribution_amount: contribution.amount, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn add_strike( + ref self: ComponentState, member: ContractAddress, round_id: u256, + ) { + // Get penalty configuration from component storage + let penalty_config = self.penalty_config.read(); + + // Get current penalty record + let mut penalty_record = self.member_penalties.read(member); + penalty_record.strikes += 1; + penalty_record.last_penalty_date = get_block_timestamp(); + + // Check if member should be banned + if penalty_record.strikes >= penalty_config.max_strikes { + penalty_record.is_banned = true; + } + + // Save updated penalty record + self.member_penalties.write(member, penalty_record); + + // Record penalty event + self._record_penalty_event(member, round_id, PenaltyEventType::Strike, 0); + + // Emit strike event + self + .emit( + Event::StrikeAdded( + StrikeAdded { + member, + round_id, + current_strikes: penalty_record.strikes, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn remove_strike(ref self: ComponentState, member: ContractAddress) { + // Get current penalty record + let mut penalty_record = self.member_penalties.read(member); + + if penalty_record.strikes > 0 { + penalty_record.strikes -= 1; + + // Check if member should be unbanned + let penalty_config = self.penalty_config.read(); + if penalty_record.is_banned && penalty_record.strikes < penalty_config.max_strikes { + penalty_record.is_banned = false; + // Note: Member re-addition is handled by the main contract + // The component only manages its own penalty state + } + + // Save updated penalty record + self.member_penalties.write(member, penalty_record); + + // Record penalty event + self._record_penalty_event(member, 0, PenaltyEventType::StrikeRemoved, 0); + + // Emit event + self + .emit( + Event::StrikeRemoved( + StrikeRemoved { + member, + removed_by: get_caller_address(), + new_strikes: penalty_record.strikes, + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + fn ban_member(ref self: ComponentState, member: ContractAddress) { + // Get current penalty record + let mut penalty_record = self.member_penalties.read(member); + penalty_record.is_banned = true; + penalty_record.strikes = self.penalty_config.read().max_strikes; + + // Save updated penalty record + self.member_penalties.write(member, penalty_record); + + // Note: Member removal is handled by the main contract + // The component only manages its own penalty state + + // Record penalty event + self._record_penalty_event(member, 0, PenaltyEventType::Ban, 0); + } + + fn unban_member(ref self: ComponentState, member: ContractAddress) { + // Get current penalty record + let mut penalty_record = self.member_penalties.read(member); + penalty_record.is_banned = false; + penalty_record.strikes = 0; + + // Save updated penalty record + self.member_penalties.write(member, penalty_record); + + // Note: Member re-addition is handled by the main contract + // The component only manages its own penalty state + + // Record penalty event + self._record_penalty_event(member, 0, PenaltyEventType::Unban, 0); + } + + fn _reset_penalty_pool(ref self: ComponentState) { + self.penalty_pool.write(0); + } + + + fn _record_penalty_event( + ref self: ComponentState, + member: ContractAddress, + round_id: u256, + event_type: PenaltyEventType, + amount: u256, + ) { + let history_count = self.penalty_history_count.read(member); + let event = PenaltyEventRecord { + member, + round_id, + event_type, + amount, + timestamp: get_block_timestamp(), + admin: get_caller_address(), + }; + + self.penalty_history.write((member, history_count), event); + self.penalty_history_count.write(member, history_count + 1); + } + + fn _update_penalty_pool(ref self: ComponentState, amount: u256) { + let current_pool = self.penalty_pool.read(); + self.penalty_pool.write(current_pool + amount); + } + } +} + diff --git a/src/interfaces/IStarkRemit.cairo b/src/interfaces/IStarkRemit.cairo index 64bc20d..234f24c 100644 --- a/src/interfaces/IStarkRemit.cairo +++ b/src/interfaces/IStarkRemit.cairo @@ -1,5 +1,5 @@ use starknet::ContractAddress; -use starkremit_contract::base::types::*; +use starkremit_contract::base::types::{*, MemberProfileData}; #[starknet::interface] pub trait IStarkRemit { @@ -271,4 +271,64 @@ pub trait IStarkRemit { // Utility Function fn get_timelock_duration(self: @TContractState) -> u64; + + // Emergency Pause Functions + fn emergency_pause_contract(ref self: TContractState, reason: felt252); + fn emergency_unpause_contract(ref self: TContractState); + fn emergency_pause_with_metadata(ref self: TContractState, reason: felt252); + fn emergency_unpause_with_metadata_clear(ref self: TContractState); + fn emergency_set_pause_meta(ref self: TContractState, reason: felt252); + fn emergency_set_ban(ref self: TContractState, member: ContractAddress, banned: bool); + + + // Penalty Management Functions + fn apply_late_fee(ref self: TContractState, member: ContractAddress, round_id: u256); + fn add_strike(ref self: TContractState, member: ContractAddress, round_id: u256); + fn remove_strike(ref self: TContractState, member: ContractAddress); + fn ban_member(ref self: TContractState, member: ContractAddress); + fn unban_member(ref self: TContractState, member: ContractAddress); + fn distribute_penalty_pool(ref self: TContractState); + + // Auto-Schedule Management Functions + fn setup_auto_schedule(ref self: TContractState, config: AutoScheduleConfig); + fn maintain_rolling_schedule(ref self: TContractState); + fn auto_activate_round(ref self: TContractState, round_id: u256); + fn auto_complete_expired_rounds(ref self: TContractState, max_iterations: u32) -> (u32, bool); + fn modify_schedule(ref self: TContractState, round_id: u256, new_deadline: u64); + + // Payment Flexibility Functions + fn setup_auto_payment( + ref self: TContractState, + member: ContractAddress, + token: ContractAddress, + amount: u256, + frequency: PaymentFrequency, + ); + fn process_early_payment( + ref self: TContractState, + member: ContractAddress, + round_id: u256, + amount: u256, + token: ContractAddress, + ) -> (u256, u256); + fn extend_grace_period(ref self: TContractState, member: ContractAddress, extension_hours: u64); + fn add_supported_token(ref self: TContractState, token: ContractAddress); + fn remove_supported_token(ref self: TContractState, token: ContractAddress); + fn update_payment_config(ref self: TContractState, config: PaymentConfig); + fn process_auto_payments(ref self: TContractState); + + // Contribution wrapper to centralize analytics update + fn contribute_round(ref self: TContractState, round_id: u256, amount: u256); + + // Member Profile Functions + fn create_member_profile(ref self: TContractState, member: ContractAddress); + fn update_reliability_rating(ref self: TContractState, member: ContractAddress, new_rating: u8); + fn get_member_profile(self: @TContractState, member: ContractAddress) -> MemberProfileData; + fn add_to_waitlist(ref self: TContractState, member: ContractAddress); + fn remove_from_waitlist(ref self: TContractState, member: ContractAddress) -> bool; + fn get_waitlist_position(self: @TContractState, member: ContractAddress) -> u32; + fn update_communication_preferences( + ref self: TContractState, member: ContractAddress, preferences: felt252, + ); + fn send_member_message(ref self: TContractState, member: ContractAddress, message: felt252); } diff --git a/src/lib.cairo b/src/lib.cairo index d9ebf7b..6434c90 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -28,8 +28,10 @@ pub mod component { pub mod mock; pub mod test; } + pub mod emergency; pub mod kyc; pub mod loan; + pub mod penalty; pub mod savings_group; pub mod token_management; pub mod transfer; @@ -38,4 +40,8 @@ pub mod component { pub mod test; pub mod user_management; } + pub mod analytics; + pub mod auto_schedule; + pub mod member_profile; + pub mod payment_flexibility; } diff --git a/src/starkremit/StarkRemit.cairo b/src/starkremit/StarkRemit.cairo index 0a3156b..e6d94a6 100644 --- a/src/starkremit/StarkRemit.cairo +++ b/src/starkremit/StarkRemit.cairo @@ -1,7 +1,9 @@ +use core::array::ArrayTrait; use core::num::traits::Zero; use openzeppelin::access::accesscontrol::AccessControlComponent; use openzeppelin::access::ownable::OwnableComponent; use openzeppelin::introspection::src5::SRC5Component; +use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin::upgrades::UpgradeableComponent; use starknet::storage::{ Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePathEntry, StoragePointerReadAccess, @@ -9,15 +11,35 @@ use starknet::storage::{ }; use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; use starkremit_contract::base::errors::{ - GovernanceErrors, GroupErrors, KYCErrors, RegistrationErrors, TransferErrors, + EmergencyErrors, GovernanceErrors, GroupErrors, KYCErrors, RegistrationErrors, TransferErrors, }; use starkremit_contract::base::events::*; use starkremit_contract::base::types::{ - Agent, AgentStatus, ContributionRound, GovRole, KYCLevel, KycLevel, KycStatus, LoanRequest, - LoanStatus, MemberContribution, ParameterBounds, ParameterHistory, RegistrationRequest, - RegistrationStatus, RoundStatus, SavingsGroup, TimelockChange, TransferData, TransferHistory, + Agent, AgentStatus, AutoPaymentSetup, AutoScheduleConfig, ContributionAnalytics, + ContributionRound, DistributionData, FinancialReport, GovRole, KYCLevel, KycLevel, KycStatus, + LoanRequest, LoanStatus, MemberAnalytics, MemberContribution, MemberPenaltyRecord, + MemberProfileData, MemberShare, ParameterBounds, ParameterHistory, PaymentConfig, + PaymentFrequency, PenaltyConfig, PenaltyEventRecord, PenaltyEventType, RegistrationRequest, + RegistrationStatus, RoundData, RoundPerformanceMetrics, RoundStatus, SavingsGroup, + ScheduledRound, SystemHealthMetrics, TimelockChange, TransferData, TransferHistory, TransferStatus, UserKycData, UserProfile, }; +use starkremit_contract::component::analytics::{ + IAnalytics, IMainContractData as AnalyticsMainContractData, +}; +use starkremit_contract::component::auto_schedule::{ + IAutoSchedule, IMainContractData as AutoScheduleMainContractData, +}; +use starkremit_contract::component::emergency::IEmergency; +use starkremit_contract::component::member_profile::{ + IMainContractData as MemberProfileMainContractData, IMemberProfile, +}; +use starkremit_contract::component::payment_flexibility::{ + IMainContractData as PaymentFlexibilityMainContractData, IPaymentFlexibility, +}; +use starkremit_contract::component::penalty::{ + IMainContractData as PenaltyMainContractData, IPenalty, +}; use starkremit_contract::interfaces::IStarkRemit; @@ -29,9 +51,15 @@ const LOAN_TERM_DAYS: u64 = 30 * 24 * 60 * 60; // 30 days in seconds #[starknet::contract] pub mod StarkRemit { use starkremit_contract::component::agent::agent_component; + use starkremit_contract::component::analytics::analytics_component; + use starkremit_contract::component::auto_schedule::auto_schedule_component; use starkremit_contract::component::contribution::contribution::contribution_component; + use starkremit_contract::component::emergency::emergency_component; use starkremit_contract::component::kyc::kyc_component; use starkremit_contract::component::loan::loan_component; + use starkremit_contract::component::member_profile::member_profile_component; + use starkremit_contract::component::payment_flexibility::payment_flexibility_component; + use starkremit_contract::component::penalty::penalty_component; use starkremit_contract::component::savings_group::savings_group_component; use starkremit_contract::component::token_management::token_management_component; use starkremit_contract::component::transfer::transfer_component; @@ -62,6 +90,17 @@ pub mod StarkRemit { event: TokenManagementEvent, ); component!(path: transfer_component, storage: transfer_component, event: TransferEvent); + component!(path: emergency_component, storage: emergency, event: EmergencyEvent); + component!(path: penalty_component, storage: penalty, event: PenaltyEvent); + component!(path: auto_schedule_component, storage: auto_schedule, event: AutoScheduleEvent); + component!( + path: payment_flexibility_component, + storage: payment_flexibility, + event: PaymentFlexibilityEvent, + ); + component!(path: analytics_component, storage: analytics, event: AnalyticsEvent); + component!(path: member_profile_component, storage: member_profile, event: MemberProfileEvent); + #[abi(embed_v0)] impl AccessControlImpl = @@ -96,6 +135,27 @@ pub mod StarkRemit { // Transfer Component (internal use only - functions exposed via IStarkRemit) impl TransferImpl = transfer_component::Transfer; + // Emergency component internal methods + impl EmergencyInternalImpl = emergency_component::InternalImpl; + + // Penalty Component + impl PenaltyInternalImpl = penalty_component::InternalImpl; + + // Auto Schedule Component (internal use only - functions exposed via IStarkRemit) + impl AutoScheduleInternalImpl = auto_schedule_component::InternalImpl; + + // Payment Flexibility Component (internal use only - functions exposed via IStarkRemit) + impl PaymentFlexibilityInternalImpl = + payment_flexibility_component::InternalImpl; + + // Analytics Component (exposed directly via component interface) + impl AnalyticsImpl = analytics_component::AnalyticsImpl; + impl AnalyticsInternalImpl = analytics_component::InternalImpl; + + // Member Profile Component (exposed directly via component interface) + impl MemberProfileImpl = member_profile_component::MemberProfileImpl; + impl MemberProfileInternalImpl = member_profile_component::InternalImpl; + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; const PROTOCOL_OWNER_ROLE: felt252 = selector!("PROTOCOL_OWNER"); @@ -260,6 +320,12 @@ pub mod StarkRemit { SavingsGroupEvent: savings_group_component::Event, TokenManagementEvent: token_management_component::Event, TransferEvent: transfer_component::Event, + EmergencyEvent: emergency_component::Event, + PenaltyEvent: penalty_component::Event, + AutoScheduleEvent: auto_schedule_component::Event, + PaymentFlexibilityEvent: payment_flexibility_component::Event, + AnalyticsEvent: analytics_component::Event, + MemberProfileEvent: member_profile_component::Event, // System Management Events AgentAuthorized: AgentAuthorized, AgentPermissionUpdated: AgentPermissionUpdated, @@ -324,6 +390,16 @@ pub mod StarkRemit { UpdateExecuted: UpdateExecuted, UpdateCancelled: UpdateCancelled, // loan_id -> timestamp_of_last_payment + EmergencyWithdrawalAll: EmergencyWithdrawalAll, + EmergencyWithdrawalMember: EmergencyWithdrawalMember, + RoundEmergencyCompleted: RoundEmergencyCompleted, + RoundEmergencyCancelled: RoundEmergencyCancelled, + RecipientChanged: RecipientChanged, + TokensRecovered: TokensRecovered, + FundsMigrated: FundsMigrated, + MemberBanned: MemberBanned, + MemberUnbanned: MemberUnbanned, + PenaltyPoolDistributed: PenaltyPoolDistributed, } @@ -355,7 +431,20 @@ pub mod StarkRemit { token_management_component: token_management_component::Storage, #[substorage(v0)] transfer_component: transfer_component::Storage, - // System Management Storage + #[substorage(v0)] + emergency: emergency_component::Storage, + #[substorage(v0)] + penalty: penalty_component::Storage, + #[substorage(v0)] + auto_schedule: auto_schedule_component::Storage, + #[substorage(v0)] + payment_flexibility: payment_flexibility_component::Storage, + #[substorage(v0)] + analytics: analytics_component::Storage, + #[substorage(v0)] + member_profile: member_profile_component::Storage, + // Emergency and Penalty System Storage + emergency_approvals: Map>, agent_permissions: Map<(ContractAddress, felt252), bool>, // (agent, permission) -> granted paused_functions: Map, // function selector -> paused multi_sig_operations: Map, // op_id -> operation data @@ -492,11 +581,195 @@ pub mod StarkRemit { // Initialize governance self.admin_roles.write(owner, GovRole::SuperAdmin); self.timelock_duration.write(86400); // 24 hours default timelock + // Initialize emergency component + self.emergency.initializer(owner); + // Initialize penalty component + self.penalty.initializer(owner); + // Initialize auto-schedule component + self.auto_schedule.initializer(owner); + // Initialize payment flexibility component + self.payment_flexibility.initializer(owner); + // Initialize analytics component + self.analytics.initializer(owner); + // Initialize member profile component + self.member_profile.initializer(owner); } // Implementation of the StarkRemit interface with KYC functions #[abi(embed_v0)] impl IStarkRemitImpl of IStarkRemit::IStarkRemit { + // Wrapper: contribute_round -> updates analytics centrally + fn contribute_round(ref self: ContractState, round_id: u256, amount: u256) { + let caller = get_caller_address(); + let now = get_block_timestamp(); + + // Read deadline to compute timeliness using real data + let round_before = self.rounds.read(round_id); + let is_on_time = now <= round_before.deadline; + + // Delegate to component for contribution state (no event emission) + self.contribution_component.contribute_round(round_id, amount); + + // Centralized analytics update + InternalFunctions::_update_analytics_after_contribution( + ref self, caller, round_id, amount, is_on_time, + ); + } + // --- Penalty Functions --- + fn apply_late_fee(ref self: ContractState, member: ContractAddress, round_id: u256) { + self.ownable.assert_only_owner(); + self.penalty.apply_late_fee(member, round_id); + } + + fn add_strike(ref self: ContractState, member: ContractAddress, round_id: u256) { + self.ownable.assert_only_owner(); + + // Get current penalty record to check if member will be automatically banned + let current_record = self.penalty.get_member_penalty_record(member); + let penalty_config = self.penalty.get_penalty_config(); + let will_be_banned = current_record.strikes + 1 >= penalty_config.max_strikes; + + // Add strike in penalty component + self.penalty.add_strike(member, round_id); + + // If member was automatically banned, remove them from main contract's member list + if will_be_banned { + self._remove_member_from_list(member); + + // Emit main contract event for automatic ban + self + .emit( + Event::MemberBanned( + MemberBanned { + member, + reason: 'max_strikes_reached', + strikes: current_record.strikes + 1, + banned_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + fn remove_strike(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + + // Get current penalty record to check if member will be automatically unbanned + let current_record = self.penalty.get_member_penalty_record(member); + let penalty_config = self.penalty.get_penalty_config(); + let will_be_unbanned = current_record.is_banned && current_record.strikes + - 1 < penalty_config.max_strikes; + + // Remove strike in penalty component + self.penalty.remove_strike(member); + + // If member was automatically unbanned, re-add them to main contract's member list + if will_be_unbanned { + self._add_member_to_list(member); + + // Emit main contract event for automatic unban + self + .emit( + Event::MemberUnbanned( + MemberUnbanned { + member, + unbanned_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + fn ban_member(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + + // First, update penalty state in component + self.penalty.ban_member(member); + + // Then, remove member from main contract's member list + self._remove_member_from_list(member); + + // Emit main contract event + self + .emit( + Event::MemberBanned( + MemberBanned { + member, + reason: 'admin_ban', + strikes: self.penalty.get_member_penalty_record(member).strikes, + banned_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn unban_member(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + + // First, update penalty state in component + self.penalty.unban_member(member); + + // Then, re-add member to main contract's member list + self._add_member_to_list(member); + + // Emit main contract event + self + .emit( + Event::MemberUnbanned( + MemberUnbanned { + member, + unbanned_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn distribute_penalty_pool(ref self: ContractState) { + self.ownable.assert_only_owner(); + + // Get distribution data from penalty component + let distribution_data = self.penalty.calculate_distribution_data(); + + if distribution_data.total_amount == 0 { + return; // No penalty pool to distribute + } + + // Execute transfers using main contract's transfer function + let mut distributed_count = 0; + let mut i = 0; + + while i < distribution_data.member_shares.len() { + let member_share = *distribution_data.member_shares.at(i); + if member_share.share > 0 { + // Transfer tokens to member + self.transfer_tokens_to_member(member_share.member, member_share.share); + distributed_count += 1; + } + i += 1; + } + + // Reset penalty pool in component after successful distribution + self.penalty.reset_penalty_pool(); + + // Emit main contract event + self + .emit( + Event::PenaltyPoolDistributed( + PenaltyPoolDistributed { + total_amount: distribution_data.total_amount, + recipient_count: distributed_count, + distribution_type: 'proportional', + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn grant_admin_role(ref self: ContractState, admin: ContractAddress) { self.accesscontrol.assert_only_role(ADMIN_ROLE); self.accesscontrol._grant_role(ADMIN_ROLE, admin); @@ -2187,6 +2460,7 @@ pub mod StarkRemit { self.emit(ContractRegistered { name, addr: new_address }); true } + /// Schedule a parameter update (SuperAdmin only, timelock) fn schedule_parameter_update(ref self: ContractState, key: felt252, value: u256) -> bool { let caller = get_caller_address(); @@ -2329,8 +2603,237 @@ pub mod StarkRemit { fn get_timelock_duration(self: @ContractState) -> u64 { self.timelock_duration.read() } + + /// Emergency pause the entire contract with metadata + fn emergency_pause_contract(ref self: ContractState, reason: felt252) { + // Only pauser can pause + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._pause_with_metadata(reason); + + self + .emit( + EmergencyPauseActivated { + function_selector: 0, // Global pause, not function-specific + caller, + expires_at: 0 // No expiry for global pause + }, + ); + } + + /// Emergency unpause the entire contract + fn emergency_unpause_contract(ref self: ContractState) { + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._unpause_with_metadata_clear(); + + self + .emit( + EmergencyPauseDeactivated { + function_selector: 0, // Global pause, not function-specific + caller, + }, + ); + } + + /// Emergency pause with metadata + fn emergency_pause_with_metadata(ref self: ContractState, reason: felt252) { + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._pause_with_metadata(reason); + + self + .emit( + EmergencyPauseActivated { + function_selector: 0, // Global pause, not function-specific + caller, + expires_at: 0 // No expiry for global pause + }, + ); + } + + /// Emergency unpause with metadata clear + fn emergency_unpause_with_metadata_clear(ref self: ContractState) { + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._unpause_with_metadata_clear(); + + self + .emit( + EmergencyPauseDeactivated { + function_selector: 0, // Global pause, not function-specific + caller, + }, + ); + } + + /// Emergency set pause metadata + fn emergency_set_pause_meta(ref self: ContractState, reason: felt252) { + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._set_pause_meta(reason); + } + + /// Emergency set ban status + fn emergency_set_ban(ref self: ContractState, member: ContractAddress, banned: bool) { + let caller = get_caller_address(); + assert(self.agent_permissions.read((caller, 'PAUSER')), 'Not authorized pauser'); + + self.emergency._set_ban(member, banned); + + if banned { + self + .emit( + Event::MemberBanned( + MemberBanned { + member, + reason: 'emergency_ban', + strikes: 0, + banned_by: caller, + timestamp: get_block_timestamp(), + }, + ), + ); + } else { + self + .emit( + Event::MemberUnbanned( + MemberUnbanned { + member, unbanned_by: caller, timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + // --- Auto-Schedule Functions --- + fn setup_auto_schedule(ref self: ContractState, config: AutoScheduleConfig) { + self.ownable.assert_only_owner(); + self.auto_schedule._setup_auto_schedule(config); + } + + fn maintain_rolling_schedule(ref self: ContractState) { + self.ownable.assert_only_owner(); + self.auto_schedule._maintain_rolling_schedule(); + } + + fn auto_activate_round(ref self: ContractState, round_id: u256) { + self.ownable.assert_only_owner(); + self.auto_schedule._auto_activate_round(round_id); + } + + fn auto_complete_expired_rounds( + ref self: ContractState, max_iterations: u32, + ) -> (u32, bool) { + self.ownable.assert_only_owner(); + self.auto_schedule._auto_complete_expired_rounds(max_iterations) + } + + fn modify_schedule(ref self: ContractState, round_id: u256, new_deadline: u64) { + self.ownable.assert_only_owner(); + self.auto_schedule._modify_schedule(round_id, new_deadline); + } + + // --- Payment Flexibility Functions --- + fn setup_auto_payment( + ref self: ContractState, + member: ContractAddress, + token: ContractAddress, + amount: u256, + frequency: PaymentFrequency, + ) { + self.ownable.assert_only_owner(); + self.payment_flexibility._setup_auto_payment(member, token, amount, frequency); + } + + fn process_early_payment( + ref self: ContractState, + member: ContractAddress, + round_id: u256, + amount: u256, + token: ContractAddress, + ) -> (u256, u256) { + self.ownable.assert_only_owner(); + self.payment_flexibility._process_early_payment(member, round_id, amount, token) + } + + fn extend_grace_period( + ref self: ContractState, member: ContractAddress, extension_hours: u64, + ) { + self.ownable.assert_only_owner(); + self.payment_flexibility._extend_grace_period(member, extension_hours); + } + + fn add_supported_token(ref self: ContractState, token: ContractAddress) { + self.ownable.assert_only_owner(); + self.payment_flexibility._add_supported_token(token); + } + + fn remove_supported_token(ref self: ContractState, token: ContractAddress) { + self.ownable.assert_only_owner(); + self.payment_flexibility._remove_supported_token(token); + } + + fn update_payment_config(ref self: ContractState, config: PaymentConfig) { + self.ownable.assert_only_owner(); + self.payment_flexibility._update_payment_config(config); + } + + fn process_auto_payments(ref self: ContractState) { + self.ownable.assert_only_owner(); + self.payment_flexibility._process_auto_payments(); + } + + // --- Member Profile Functions --- + fn create_member_profile(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + self.member_profile.create_member_profile(member); + } + + fn update_reliability_rating( + ref self: ContractState, member: ContractAddress, new_rating: u8, + ) { + self.ownable.assert_only_owner(); + self.member_profile.update_reliability_rating(member, new_rating); + } + + fn get_member_profile(self: @ContractState, member: ContractAddress) -> MemberProfileData { + self.member_profile.get_member_profile(member) + } + + fn add_to_waitlist(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + self.member_profile.add_to_waitlist(member); + } + + fn remove_from_waitlist(ref self: ContractState, member: ContractAddress) -> bool { + self.ownable.assert_only_owner(); + self.member_profile.remove_from_waitlist(member) + } + + fn get_waitlist_position(self: @ContractState, member: ContractAddress) -> u32 { + self.member_profile.get_waitlist_position(member) + } + + fn update_communication_preferences( + ref self: ContractState, member: ContractAddress, preferences: felt252, + ) { + self.ownable.assert_only_owner(); + self.member_profile.update_communication_preferences(member, preferences); + } + + fn send_member_message(ref self: ContractState, member: ContractAddress, message: felt252) { + self.ownable.assert_only_owner(); + self.member_profile.send_member_message(member, message); + } } + // --- System Management Functions --- #[generate_trait] impl SystemManagement of SystemManagementTrait { @@ -2511,7 +3014,440 @@ pub mod StarkRemit { } } - // Internal helper functions + // --- Emergency Functions --- + #[generate_trait] + pub impl Emergency of EmergencyTrait { + fn emergency_withdraw_all(ref self: ContractState) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); // Only callable when paused + + // Business Logic: Calculate proportional withdrawal based on member contributions + let total_contract_balance = self.get_contract_token_balance(); + assert(total_contract_balance > 0, EmergencyErrors::NO_FUNDS_TO_WITHDRAW); + + // Get all active members and their contribution ratios + let mut total_contributions = 0; + let mut member_shares = ArrayTrait::new(); + + // Calculate total contributions across all rounds + let mut round_id = 1; + while round_id <= self.round_ids.read() { + let round = self.rounds.read(round_id); + if round.status == RoundStatus::Active { + total_contributions += round.total_contributions; + } + round_id += 1; + } + + // Calculate each member's proportional share + let mut member_index = 0; + while member_index < self.member_count.read() { + let member_address = self.member_by_index.read(member_index); + if self.members.read(member_address) { + let member_total_contribution = self + .calculate_member_total_contribution(member_address); + if member_total_contribution > 0 { + let proportional_share = (member_total_contribution + * total_contract_balance) + / total_contributions; + member_shares.append((member_address, proportional_share)); + } + } + member_index += 1; + } + + // Execute proportional withdrawals + let mut shares_index = 0; + while shares_index < member_shares.len() { + let (member, share) = member_shares[shares_index]; + if *share > 0 { + self.transfer_tokens_to_member(*member, *share); + } + shares_index += 1; + } + + // Emit event for audit trail + self + .emit( + Event::EmergencyWithdrawalAll( + EmergencyWithdrawalAll { + total_amount: total_contract_balance, + member_count: member_shares.len().try_into().unwrap(), + executed_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn emergency_withdraw_member(ref self: ContractState, member: ContractAddress) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Withdraw member's specific contributions + assert(self.members.read(member), EmergencyErrors::MEMBER_NOT_EXISTS); + + let member_contribution = self.calculate_member_total_contribution(member); + assert(member_contribution > 0, EmergencyErrors::MEMBER_NO_CONTRIBUTIONS); + + // Transfer member's contributions back + self.transfer_tokens_to_member(member, member_contribution); + + // Update member status - remove from active members + self.members.write(member, false); + self.member_count.write(self.member_count.read() - 1); + + self + .emit( + Event::EmergencyWithdrawalMember( + EmergencyWithdrawalMember { + member, + amount: member_contribution, + executed_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn emergency_complete_round(ref self: ContractState, round_id: u256) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Force complete a stuck round + let mut round = self.rounds.read(round_id); + assert(round.status == RoundStatus::Active, EmergencyErrors::ROUND_NOT_ACTIVE); + + // Check if round has contributions + if round.total_contributions > 0 { + // Transfer funds to recipient + self.transfer_tokens_to_member(round.recipient, round.total_contributions); + + // Update round status and set completion timestamp + round.status = RoundStatus::Completed; + round.completed_at = get_block_timestamp(); + self.rounds.write(round_id, round); + + // Update analytics + self.update_round_analytics(round_id, RoundStatus::Completed); + + self + .emit( + Event::RoundEmergencyCompleted( + RoundEmergencyCompleted { + round_id, + recipient: round.recipient, + amount: round.total_contributions, + completed_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } else { + // No contributions - just cancel the round + round.status = RoundStatus::Cancelled; + self.rounds.write(round_id, round); + + self + .emit( + Event::RoundEmergencyCancelled( + RoundEmergencyCancelled { + round_id, + cancelled_by: get_caller_address(), + reason: 'no_contributions', + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + fn emergency_change_recipient( + ref self: ContractState, round_id: u256, new_recipient: ContractAddress, + ) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Change recipient due to member issues + assert(!new_recipient.is_zero(), EmergencyErrors::INVALID_RECIPIENT); + assert(self.members.read(new_recipient), EmergencyErrors::RECIPIENT_NOT_MEMBER); + + let mut round = self.rounds.read(round_id); + assert(round.status == RoundStatus::Active, EmergencyErrors::ROUND_NOT_ACTIVE); + assert(round.recipient != new_recipient, EmergencyErrors::RECIPIENT_ALREADY_SET); + + let old_recipient = round.recipient; + round.recipient = new_recipient; + self.rounds.write(round_id, round); + + self + .emit( + Event::RecipientChanged( + RecipientChanged { + round_id, + old_recipient, + new_recipient, + changed_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn emergency_cancel_round(ref self: ContractState, round_id: u256) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Cancel round and refund all contributions + let round = self.rounds.read(round_id); + assert(round.status == RoundStatus::Active, EmergencyErrors::ROUND_NOT_ACTIVE); + + if round.total_contributions > 0 { + // Refund contributions proportionally to contributors + self.refund_round_contributions(round_id); + } + + // Update round status + let mut updated_round = round; + updated_round.status = RoundStatus::Cancelled; + self.rounds.write(round_id, updated_round); + + self + .emit( + Event::RoundEmergencyCancelled( + RoundEmergencyCancelled { + round_id, + cancelled_by: get_caller_address(), + reason: 'emergency_cancellation', + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn emergency_recover_tokens(ref self: ContractState, token: ContractAddress, amount: u256) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Recover accidentally sent tokens + assert(!token.is_zero(), EmergencyErrors::INVALID_TOKEN_ADDRESS); + assert(amount > 0, EmergencyErrors::INVALID_AMOUNT); + + // Calculate unallocated balance: total_contract_balance - total_allocated_tokens + let total_contract_balance = self.get_contract_token_balance_specific(token); + let total_allocated_tokens = self._calculate_total_allocated_tokens(token); + let unallocated_balance = total_contract_balance - total_allocated_tokens; + + // Ensure we only recover unallocated tokens + assert(unallocated_balance >= amount, EmergencyErrors::INSUFFICIENT_BALANCE); + + // Transfer tokens to owner + let owner = self.ownable.owner(); + self.transfer_specific_tokens_to_address(token, owner, amount); + + self + .emit( + Event::TokensRecovered( + TokensRecovered { + token, + amount, + recovered_by: get_caller_address(), + recipient: owner, + timestamp: get_block_timestamp(), + }, + ), + ); + } + + fn emergency_migrate_funds(ref self: ContractState, new_contract: ContractAddress) { + self.ownable.assert_only_owner(); + self.emergency.assert_paused(); + + // Business Logic: Migrate all funds to new contract + assert(!new_contract.is_zero(), EmergencyErrors::INVALID_CONTRACT_ADDRESS); + assert( + new_contract != starknet::get_contract_address(), + EmergencyErrors::CANNOT_MIGRATE_TO_SELF, + ); + + // Verify the target contract is registered in the contract registry + // or implements a specific interface + let registered_migration_target = self.contract_registry.read('migration_target'); + assert( + new_contract == registered_migration_target + || registered_migration_target.is_zero(), + EmergencyErrors::INVALID_MIGRATION_TARGET, + ); + + let total_balance = self.get_contract_token_balance(); + assert(total_balance > 0, EmergencyErrors::NO_FUNDS_TO_MIGRATE); + + // Transfer all funds to new contract + self.transfer_tokens_to_address(new_contract, total_balance); + + self + .emit( + Event::FundsMigrated( + FundsMigrated { + new_contract, + amount: total_balance, + migrated_by: get_caller_address(), + timestamp: get_block_timestamp(), + }, + ), + ); + } + } + + // Implementation of IMainContractData trait for penalty component + impl MainContractDataImpl of PenaltyMainContractData { + fn get_member_contribution_data( + self: @ContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution { + let contribution = self.member_contributions.read((round_id, member)); + MemberContribution { + member, amount: contribution.amount, contributed_at: contribution.contributed_at, + } + } + + fn get_round_data(self: @ContractState, round_id: u256) -> RoundData { + let round = self.rounds.read(round_id); + RoundData { + deadline: round.deadline, + completed_at: round.completed_at, + status: round.status, + total_contributions: round.total_contributions, + } + } + + fn get_member_status(self: @ContractState, member: ContractAddress) -> bool { + self.members.read(member) + } + + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_round_ids(self: @ContractState) -> u256 { + self.round_ids.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + } + + // Implementation of IMainContractData trait for auto-schedule component + impl AutoScheduleMainContractDataImpl of AutoScheduleMainContractData { + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + + fn get_current_round_id(self: @ContractState) -> u256 { + self.round_ids.read() + } + + fn create_round( + ref self: ContractState, recipient: ContractAddress, deadline: u64, + ) -> u256 { + // Create a new round using existing round creation logic + let round_id = self.round_ids.read() + 1; + let round = ContributionRound { + round_id, + recipient, + deadline, + completed_at: 0, + status: RoundStatus::Scheduled, + total_contributions: 0, + }; + self.rounds.write(round_id, round); + self.round_ids.write(round_id); + round_id + } + } + + // Implementation of IMainContractData trait for payment flexibility component + impl PaymentFlexibilityMainContractDataImpl of PaymentFlexibilityMainContractData< + ContractState, + > { + fn get_round_data(self: @ContractState, round_id: u256) -> RoundData { + let round = self.rounds.read(round_id); + RoundData { + deadline: round.deadline, + completed_at: round.completed_at, + status: round.status, + total_contributions: round.total_contributions, + } + } + + fn get_member_status(self: @ContractState, member: ContractAddress) -> bool { + self.members.read(member) + } + + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + } + + // Implementation of IMainContractData trait for analytics component + impl AnalyticsMainContractDataImpl of AnalyticsMainContractData { + fn get_round_data(self: @ContractState, round_id: u256) -> RoundData { + let round = self.rounds.read(round_id); + RoundData { + deadline: round.deadline, + completed_at: round.completed_at, + status: round.status, + total_contributions: round.total_contributions, + } + } + + fn get_member_contribution_data( + self: @ContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution { + self.member_contributions.read((round_id, member)) + } + + fn get_member_status(self: @ContractState, member: ContractAddress) -> bool { + self.members.read(member) + } + + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + + fn get_round_ids(self: @ContractState) -> u256 { + self.round_ids.read() + } + } + + // Implementation of IMainContractData trait for member profile component + impl MemberProfileMainContractDataImpl of MemberProfileMainContractData { + fn get_member_status(self: @ContractState, member: ContractAddress) -> bool { + self.members.read(member) + } + + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + } + + #[generate_trait] impl InternalFunctions of InternalFunctionsTrait { fn _validate_kyc_and_limits(self: @ContractState, user: ContractAddress, amount: u256) { @@ -2586,6 +3522,45 @@ pub mod StarkRemit { self.single_limits.write(3, 50000_000_000_000_000_000_000); // 50,000 tokens } + fn _calculate_total_allocated_tokens(self: @ContractState, token: ContractAddress) -> u256 { + // Calculate total allocated tokens = member balances + tokens locked in ongoing rounds + let mut total_allocated = 0_u256; + + // Get the primary token address for comparison + let primary_token = self.token_address.read(); + assert(token == primary_token, 'Only primary token supported'); + + // If this is the primary token, calculate allocated tokens + if token == primary_token { + // Add member balances for the primary token + let member_count = self.member_count.read(); + let mut i = 0_u32; + while i < member_count { + let member = self.member_by_index.read(i); + if self.members.read(member) { + let member_balance = self.balances.read(member); + total_allocated += member_balance; + } + i += 1; + } + + // Add tokens locked in ongoing rounds (active rounds with contributions) + let current_round_id = self.round_ids.read(); + if current_round_id > 0 { + let mut round_id = 1_u256; + while round_id <= current_round_id { + let round = self.rounds.read(round_id); + // Only count active rounds that have contributions + if round.status == RoundStatus::Active && round.total_contributions > 0 { + total_allocated += round.total_contributions; + } + round_id += 1; + } + } + } + total_allocated + } + fn _record_transfer_history( ref self: ContractState, transfer_id: u256, @@ -2639,5 +3614,166 @@ pub mod StarkRemit { group_id } + + // Helper functions for emergency operations + fn get_contract_token_balance(self: @ContractState) -> u256 { + // Get contract's balance of the primary token + let token_address = self.token_address.read(); + let erc20_dispatcher = IERC20Dispatcher { contract_address: token_address }; + erc20_dispatcher.balance_of(starknet::get_contract_address()) + } + + fn get_contract_token_balance_specific( + self: @ContractState, token: ContractAddress, + ) -> u256 { + // Get contract's balance of a specific token + let erc20_dispatcher = IERC20Dispatcher { contract_address: token }; + erc20_dispatcher.balance_of(starknet::get_contract_address()) + } + + fn transfer_tokens_to_member( + ref self: ContractState, member: ContractAddress, amount: u256, + ) { + // Transfer tokens to a member + let token_address = self.token_address.read(); + let erc20_dispatcher = IERC20Dispatcher { contract_address: token_address }; + assert(erc20_dispatcher.transfer(member, amount), 'Transfer failed'); + } + + fn transfer_tokens_to_address( + ref self: ContractState, recipient: ContractAddress, amount: u256, + ) { + // Transfer tokens to any address + let token_address = self.token_address.read(); + let erc20_dispatcher = IERC20Dispatcher { contract_address: token_address }; + assert(erc20_dispatcher.transfer(recipient, amount), 'Transfer failed'); + } + + fn transfer_specific_tokens_to_address( + ref self: ContractState, + token: ContractAddress, + recipient: ContractAddress, + amount: u256, + ) { + // Transfer specific tokens to an address + let erc20_dispatcher = IERC20Dispatcher { contract_address: token }; + assert(erc20_dispatcher.transfer(recipient, amount), 'Transfer failed'); + } + + fn calculate_member_total_contribution( + self: @ContractState, member: ContractAddress, + ) -> u256 { + // Calculate total contributions across all rounds for a member + let mut total = 0; + let mut round_id = 1; + while round_id <= self.round_ids.read() { + let contribution = self.member_contributions.read((round_id, member)); + total += contribution.amount; + round_id += 1; + } + total + } + + fn refund_round_contributions(ref self: ContractState, round_id: u256) { + // Refund all contributions for a specific round + let mut member_index = 0; + while member_index < self.member_count.read() { + let member = self.member_by_index.read(member_index); + if self.members.read(member) { + let contribution = self.member_contributions.read((round_id, member)); + if contribution.amount > 0 { + self.transfer_tokens_to_member(member, contribution.amount); + } + } + member_index += 1; + } + } + + fn update_round_analytics(ref self: ContractState, round_id: u256, status: RoundStatus) { + // Update analytics when round status changes + let round = self.rounds.read(round_id); + let participant_count = self.member_count.read(); // Simplified participant count + self + .analytics + ._update_round_metrics( + round_id, status, round.total_contributions, participant_count, + ); + self.analytics._update_contribution_analytics(); + self.analytics._update_system_health(); + } + + // Private helper function to remove member from member list + fn _remove_member_from_list(ref self: ContractState, member: ContractAddress) { + // Check if member is currently active + if !self.members.read(member) { + return; // Already removed + } + + // Mark member as inactive + self.members.write(member, false); + + // Decrease member count + let current_count = self.member_count.read(); + self.member_count.write(current_count - 1); + + // Find and remove member from member_by_index + let mut i = 0; + let total_members = current_count; + + while i < total_members { + let member_at_index = self.member_by_index.read(i); + if member_at_index == member { + // Found the member, remove by setting to zero address + self.member_by_index.write(i, 0.try_into().unwrap()); + break; + } + i += 1; + } + } + + // Internal function to update analytics after contribution + fn _update_analytics_after_contribution( + ref self: ContractState, + member: ContractAddress, + round_id: u256, + amount: u256, + is_on_time: bool, + ) { + // Update member analytics + self.analytics._update_member_analytics(member, is_on_time, amount, round_id); + + // Update round performance metrics + let round = self.rounds.read(round_id); + let participant_count = 1; // Simplified - would need proper participant count logic + self + .analytics + ._update_round_metrics( + round_id, round.status, round.total_contributions, participant_count, + ); + + // Update contribution analytics + self.analytics._update_contribution_analytics(); + + // Update system health + self.analytics._update_system_health(); + } + + // Private helper function to add member back to member list + fn _add_member_to_list(ref self: ContractState, member: ContractAddress) { + // Check if member is already active + if self.members.read(member) { + return; // Already active + } + + // Mark member as active + self.members.write(member, true); + + // Increase member count + let current_count = self.member_count.read(); + self.member_count.write(current_count + 1); + + // Add member to member_by_index at the end + self.member_by_index.write(current_count, member); + } } } diff --git a/tests/test_analytics_component.cairo b/tests/test_analytics_component.cairo new file mode 100644 index 0000000..0c7c68f --- /dev/null +++ b/tests/test_analytics_component.cairo @@ -0,0 +1,191 @@ +use starknet::ContractAddress; +use starknet::testing::{set_block_timestamp, set_caller_address}; +use starkremit_contract::base::types::{MemberContribution, RoundData, RoundStatus}; +use starkremit_contract::component::analytics::analytics_component::{AnalyticsComponent, Storage}; +use starkremit_contract::component::analytics::{IAnalytics, IMainContractData}; + +// Mock implementation of IMainContractData for testing +#[starknet::interface] +trait IMockMainContract { + fn get_round_data(self: @TContractState, round_id: u256) -> RoundData; + fn get_member_contribution_data( + self: @TContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution; + fn get_member_status(self: @TContractState, member: ContractAddress) -> bool; + fn get_member_count(self: @TContractState) -> u32; + fn get_member_by_index(self: @TContractState, index: u32) -> ContractAddress; + fn get_round_ids(self: @TContractState) -> u256; +} + +#[starknet::contract] +mod MockMainContract { + use starknet::storage::Map; + use super::*; + + #[storage] + struct Storage { + rounds: Map, + member_contributions: Map<(u256, ContractAddress), MemberContribution>, + members: Map, + member_count: u32, + member_by_index: Map, + round_ids: u256, + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.member_count.write(2); + self.member_by_index.write(0, 0x123.try_into().unwrap()); + self.member_by_index.write(1, 0x456.try_into().unwrap()); + self.round_ids.write(1); + + // Setup test data + let round_data = RoundData { + deadline: 1000, completed_at: 0, status: RoundStatus::Active, total_contributions: 1000, + }; + self.rounds.write(1, round_data); + + let member_contribution = MemberContribution { + member: 0x123.try_into().unwrap(), amount: 500, contributed_at: 500, + }; + self.member_contributions.write((1, 0x123.try_into().unwrap()), member_contribution); + + self.members.write(0x123.try_into().unwrap(), true); + self.members.write(0x456.try_into().unwrap(), true); + } + + impl MockMainContractImpl of IMockMainContract { + fn get_round_data(self: @ContractState, round_id: u256) -> RoundData { + self.rounds.read(round_id) + } + + fn get_member_contribution_data( + self: @ContractState, round_id: u256, member: ContractAddress, + ) -> MemberContribution { + self.member_contributions.read((round_id, member)) + } + + fn get_member_status(self: @ContractState, member: ContractAddress) -> bool { + self.members.read(member) + } + + fn get_member_count(self: @ContractState) -> u32 { + self.member_count.read() + } + + fn get_member_by_index(self: @ContractState, index: u32) -> ContractAddress { + self.member_by_index.read(index) + } + + fn get_round_ids(self: @ContractState) -> u256 { + self.round_ids.read() + } + } +} + +#[test] +fn test_analytics_initialization() { + let mut state = AnalyticsComponent::contract_state_for_testing(); + let admin: ContractAddress = 0x123.try_into().unwrap(); + + set_caller_address(admin); + set_block_timestamp(1000); + + AnalyticsComponent::InternalImpl::initializer(ref state, admin); + + // Test that analytics is properly initialized + let analytics = IAnalytics::get_contribution_analytics(@state); + assert(analytics.total_rounds == 0, 'Should start with 0 rounds'); + assert(analytics.successful_rounds == 0, 'Should start with 0 successful rounds'); + assert(analytics.failed_rounds == 0, 'Should start with 0 failed rounds'); +} + +#[test] +fn test_member_analytics_update() { + let mut state = AnalyticsComponent::contract_state_for_testing(); + let admin: ContractAddress = 0x123.try_into().unwrap(); + let member: ContractAddress = 0x456.try_into().unwrap(); + + set_caller_address(admin); + set_block_timestamp(1000); + + AnalyticsComponent::InternalImpl::initializer(ref state, admin); + + // Update member analytics + AnalyticsComponent::InternalImpl::_update_member_analytics( + ref state, member, true, // payment_made + 1000, // amount + 1 // round_id + ); + + // Check member analytics + let member_analytics = IAnalytics::get_member_analytics(@state, member); + assert(member_analytics.total_contributions == 1000, 'Total contributions should be 1000'); + assert(member_analytics.on_time_payments == 1, 'On time payments should be 1'); + assert(member_analytics.missed_payments == 0, 'Missed payments should be 0'); + assert(member_analytics.reliability_score == 100, 'Reliability score should be 100'); +} + +#[test] +fn test_round_performance_update() { + let mut state = AnalyticsComponent::contract_state_for_testing(); + let admin: ContractAddress = 0x123.try_into().unwrap(); + + set_caller_address(admin); + set_block_timestamp(1000); + + AnalyticsComponent::InternalImpl::initializer(ref state, admin); + + // Update round metrics + AnalyticsComponent::InternalImpl::_update_round_metrics( + ref state, + 1, // round_id + RoundStatus::Completed, + 1000, // total_contributions + 2 // participant_count + ); + + // Check round performance + let round_performance = IAnalytics::get_round_performance(@state, 1); + assert(round_performance.round_id == 1, 'Round ID should be 1'); + assert(round_performance.total_contributions == 1000, 'Total contributions should be 1000'); + assert(round_performance.participant_count == 2, 'Participant count should be 2'); +} + +#[test] +fn test_system_health_update() { + let mut state = AnalyticsComponent::contract_state_for_testing(); + let admin: ContractAddress = 0x123.try_into().unwrap(); + + set_caller_address(admin); + set_block_timestamp(1000); + + AnalyticsComponent::InternalImpl::initializer(ref state, admin); + + // Update system health + AnalyticsComponent::InternalImpl::_update_system_health(ref state); + + // Check system health + let system_health = IAnalytics::get_system_health(@state); + assert(system_health.last_health_check == 1000, 'Last health check should be 1000'); + assert(system_health.security_score > 0, 'Security score should be positive'); +} + +#[test] +fn test_financial_report_generation() { + let mut state = AnalyticsComponent::contract_state_for_testing(); + let admin: ContractAddress = 0x123.try_into().unwrap(); + + set_caller_address(admin); + set_block_timestamp(1000); + + AnalyticsComponent::InternalImpl::initializer(ref state, admin); + + // Generate financial report + let report = IAnalytics::generate_financial_report(@state, 0, 2000); + + // Check report + assert(report.period_start == 0, 'Period start should be 0'); + assert(report.period_end == 2000, 'Period end should be 2000'); + assert(report.active_members == 0, 'Active members should be 0 initially'); +} diff --git a/tests/test_auto_schedule_component.cairo b/tests/test_auto_schedule_component.cairo new file mode 100644 index 0000000..a035498 --- /dev/null +++ b/tests/test_auto_schedule_component.cairo @@ -0,0 +1,241 @@ +// use starknet::ContractAddress; +// use starknet::testing::{set_caller_address, set_block_timestamp}; +// use starknet::contract_address_const; +// use starkremit_contract::component::auto_schedule::{ +// auto_schedule_component, IAutoScheduleDispatcher, IAutoScheduleDispatcherTrait, +// AutoScheduleConfig, ScheduledRound +// }; +// use starkremit_contract::base::types::RoundStatus; + +// const ADMIN: felt252 = 0x123; +// const NON_ADMIN: felt252 = 0x456; +// const MEMBER1: felt252 = 0x789; +// const MEMBER2: felt252 = 0xABC; + +// fn setup() -> ContractAddress { +// let admin_address = contract_address_const::(); +// let contract_address = contract_address_const::<0x1>(); + +// set_caller_address(admin_address); +// set_block_timestamp(1000); + +// contract_address +// } + +// fn get_default_auto_schedule_config() -> AutoScheduleConfig { +// AutoScheduleConfig { +// round_duration_days: 30, +// start_date: 1000, +// auto_activation_enabled: true, +// auto_completion_enabled: true, +// rolling_schedule_count: 3, +// } +// } + +// #[test] +// fn test_setup_auto_schedule() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// let retrieved_config = auto_schedule.get_auto_schedule_config(); +// assert!(retrieved_config.round_duration_days == 30, "Round duration should match"); +// assert!(retrieved_config.auto_activation_enabled, "Auto activation should be enabled"); +// assert!(retrieved_config.rolling_schedule_count == 3, "Rolling schedule count should match"); +// } + +// #[test] +// fn test_get_current_active_round() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Initially no active round +// let active_round_id = auto_schedule.get_current_active_round(); +// assert!(active_round_id == 0, "No active round should exist initially"); +// } + +// #[test] +// fn test_get_next_scheduled_rounds() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Get next scheduled rounds +// let scheduled_rounds = auto_schedule.get_next_scheduled_rounds(5); +// // Initially should be empty or have few rounds +// assert!(scheduled_rounds.len() <= 5, "Should not return more than requested"); +// } + +// #[test] +// fn test_maintain_rolling_schedule() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Maintain rolling schedule +// auto_schedule.maintain_rolling_schedule(); + +// // Should create future rounds based on rolling_schedule_count +// let scheduled_rounds = auto_schedule.get_next_scheduled_rounds(5); +// assert!(scheduled_rounds.len() >= 0, "Should have created scheduled rounds"); +// } + +// #[test] +// fn test_modify_schedule() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// let round_id = 1_u256; +// let new_deadline = 2000_u64; + +// // This should work even if round doesn't exist yet (implementation handles it) +// auto_schedule.modify_schedule(round_id, new_deadline); +// } + +// #[test] +// fn test_auto_complete_expired_rounds() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Move time forward to simulate expired rounds +// set_block_timestamp(5000); + +// // Try to complete expired rounds +// auto_schedule.auto_complete_expired_rounds(); + +// // This should complete any rounds that have expired +// // The function should not fail even if no rounds exist +// } + +// #[test] +// #[should_panic(expected: ('NOT_ADMIN',))] +// fn test_setup_auto_schedule_unauthorized() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; +// let non_admin_address = contract_address_const::(); + +// set_caller_address(non_admin_address); + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); +// } + +// #[test] +// #[should_panic(expected: ('INVALID_CONFIG',))] +// fn test_setup_invalid_config() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let invalid_config = AutoScheduleConfig { +// round_duration_days: 0, // Invalid: zero duration +// start_date: 1000, +// auto_activation_enabled: true, +// auto_completion_enabled: true, +// rolling_schedule_count: 3, +// }; + +// auto_schedule.setup_auto_schedule(invalid_config); +// } + +// #[test] +// #[should_panic(expected: ('INVALID_CONFIG',))] +// fn test_setup_invalid_rolling_count() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let invalid_config = AutoScheduleConfig { +// round_duration_days: 30, +// start_date: 1000, +// auto_activation_enabled: true, +// auto_completion_enabled: true, +// rolling_schedule_count: 0, // Invalid: zero rolling count +// }; + +// auto_schedule.setup_auto_schedule(invalid_config); +// } + +// #[test] +// #[should_panic(expected: ('INVALID_CONFIG',))] +// fn test_setup_excessive_rolling_count() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let invalid_config = AutoScheduleConfig { +// round_duration_days: 30, +// start_date: 1000, +// auto_activation_enabled: true, +// auto_completion_enabled: true, +// rolling_schedule_count: 10, // Invalid: too high +// }; + +// auto_schedule.setup_auto_schedule(invalid_config); +// } + +// #[test] +// #[should_panic(expected: ('NOT_ADMIN',))] +// fn test_modify_schedule_unauthorized() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; +// let non_admin_address = contract_address_const::(); + +// // Setup as admin first +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Try to modify as non-admin +// set_caller_address(non_admin_address); +// auto_schedule.modify_schedule(1_u256, 2000_u64); +// } + +// #[test] +// fn test_schedule_with_disabled_features() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = AutoScheduleConfig { +// round_duration_days: 30, +// start_date: 1000, +// auto_activation_enabled: false, // Disabled +// auto_completion_enabled: false, // Disabled +// rolling_schedule_count: 2, +// }; + +// auto_schedule.setup_auto_schedule(config); + +// // Auto completion should do nothing when disabled +// auto_schedule.auto_complete_expired_rounds(); + +// let retrieved_config = auto_schedule.get_auto_schedule_config(); +// assert!(!retrieved_config.auto_activation_enabled, "Auto activation should be disabled"); +// assert!(!retrieved_config.auto_completion_enabled, "Auto completion should be disabled"); +// } + +// #[test] +// fn test_future_round_limit() { +// let contract_address = setup(); +// let auto_schedule = IAutoScheduleDispatcher { contract_address }; + +// let config = get_default_auto_schedule_config(); +// auto_schedule.setup_auto_schedule(config); + +// // Request more rounds than available +// let scheduled_rounds = auto_schedule.get_next_scheduled_rounds(100); + +// // Should not return more than reasonable number of future rounds +// assert!(scheduled_rounds.len() <= 100, "Should handle large requests gracefully"); +// } diff --git a/tests/test_emergency_component.cairo b/tests/test_emergency_component.cairo new file mode 100644 index 0000000..6395d9a --- /dev/null +++ b/tests/test_emergency_component.cairo @@ -0,0 +1,269 @@ +use core::array::ArrayTrait; // Needed for array! macro and Serde +// Required snforge_std imports for deployment and cheatcodes +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_block_timestamp, + start_cheat_caller_address, stop_cheat_block_timestamp, stop_cheat_caller_address, +}; +use starknet::ContractAddress; +// Replaced starknet::testing imports with snforge_std cheatcodes +use starknet::contract_address_const; +use starkremit_contract::component::emergency::{ + EmergencyConfig, IEmergency, IEmergencyDispatcher, IEmergencyDispatcherTrait, + emergency_component, +}; + +const ADMIN: felt252 = 0x123; +const NON_ADMIN: felt252 = 0x456; +const MEMBER: felt252 = 0x789; + +#[starknet::contract] +mod TestContract { + use super::*; + + component!(path: emergency_component, storage: emergency, event: EmergencyEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + emergency: emergency_component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + EmergencyEvent: emergency_component::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, admin: ContractAddress) { + self.emergency.initializer(admin); + } + + #[abi(embed_v0)] + impl EmergencyInternalImpl = emergency_component::InternalImpl; +} + +// Fixed setup function to correctly deploy the contract and set initial cheats +fn setup() -> ContractAddress { + let admin_address = contract_address_const::(); + + let contract_class = declare("TestContract").unwrap().contract_class(); + + let mut constructor_calldata = array![]; + Serde::serialize(@admin_address, ref constructor_calldata); + + let (contract_address, _) = contract_class.deploy(@constructor_calldata).unwrap(); + + start_cheat_caller_address(contract_address, admin_address); + start_cheat_block_timestamp(contract_address, 1000); + + contract_address +} + +#[test] +fn test_emergency_initialization() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // Test that component is initialized correctly + assert!(!emergency.is_paused(), "Contract should start unpaused"); + assert!(emergency.get_pause_reason() == 0, "Initial pause reason should be 0"); + // Assuming initializer does not set a timestamp for pause, so it should be 0 initially + assert!(emergency.get_pause_timestamp() == 0, "Initial pause timestamp should be 0"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_pause_unpause() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // Test pause + emergency.pause(); + assert!(emergency.is_paused(), "Contract should be paused"); + + // Test unpause + emergency.unpause(); + assert!(!emergency.is_paused(), "Contract should be unpaused"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_pause_with_metadata() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // First pause the contract + emergency.pause(); + + // Set pause metadata + let reason = 'emergency_maintenance'; + emergency.set_pause_meta(reason); + + assert!(emergency.get_pause_reason() == reason, "Pause reason should match"); + // The timestamp should be the cheated block timestamp from setup (1000) or a later value + assert!(emergency.get_pause_timestamp() > 0, "Pause timestamp should be set"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_unpause_with_metadata_clear() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // First pause the contract with metadata + emergency.pause(); + emergency.set_pause_meta('test_reason'); + + // Unpause and clear metadata + emergency.unpause_with_metadata_clear(); + + assert!(!emergency.is_paused(), "Contract should be unpaused"); + assert!(emergency.get_pause_reason() == 0, "Pause reason should be cleared"); + assert!(emergency.get_pause_timestamp() == 0, "Pause timestamp should be cleared"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_ban_unban_member() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + let member_address = contract_address_const::(); + + // Test ban member + emergency.set_ban(member_address, true); + assert!(emergency.is_banned(member_address), "Member should be banned"); + + // Test unban member + emergency.set_ban(member_address, false); + assert!(!emergency.is_banned(member_address), "Member should be unbanned"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_config() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + let config = EmergencyConfig { emergency_cooldown: 86400, required_approvals: 3 }; + + emergency.set_config(config); + let retrieved_config = emergency.get_config(); + + assert!(retrieved_config.emergency_cooldown == 86400, "Cooldown should match"); + assert!(retrieved_config.required_approvals == 3, "Required approvals should match"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_assert_not_paused_success() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // Should not panic when contract is not paused + emergency.assert_not_paused(); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +#[should_panic(expected: ('Emergency: contract is paused',))] +fn test_emergency_assert_not_paused_panic() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // Pause the contract + emergency.pause(); + + // Should panic when contract is paused + emergency.assert_not_paused(); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +#[should_panic(expected: ('Emergency: not admin',))] +fn test_emergency_pause_unauthorized() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + let non_admin_address = contract_address_const::(); + + start_cheat_caller_address(contract_address, non_admin_address); + emergency.pause(); // This should panic + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +#[should_panic(expected: ('Emergency: contract is paused',))] +fn test_emergency_double_pause() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + emergency.pause(); + emergency.pause(); // Should panic + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +#[should_panic(expected: ('Emergency: contract not paused',))] +fn test_emergency_set_pause_meta_when_not_paused() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + // Try to set pause metadata without pausing first + emergency.set_pause_meta('test_reason'); // Should panic + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +#[should_panic(expected: ('Emergency: contract not paused',))] +fn test_emergency_unpause_when_not_paused() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + emergency.unpause(); // Should panic as contract is not paused + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} + +#[test] +fn test_emergency_component_integration() { + let contract_address = setup(); + let emergency = IEmergencyDispatcher { contract_address }; + + assert!(!emergency.is_paused(), "Should start unpaused"); + + // Pause with reason + emergency.pause(); + emergency.set_pause_meta('emergency_stop'); + assert!(emergency.is_paused(), "Should be paused"); + assert!(emergency.get_pause_reason() == 'emergency_stop', "Reason should match"); + + emergency.unpause_with_metadata_clear(); + assert!(!emergency.is_paused(), "Should be unpaused"); + assert!(emergency.get_pause_reason() == 0, "Reason should be cleared"); + + stop_cheat_caller_address(contract_address); + stop_cheat_block_timestamp(contract_address); +} diff --git a/tests/test_integration.cairo b/tests/test_integration.cairo new file mode 100644 index 0000000..f273cd3 --- /dev/null +++ b/tests/test_integration.cairo @@ -0,0 +1,227 @@ +// use starknet::ContractAddress; +// use starknet::testing::{set_caller_address, set_block_timestamp}; +// use starknet::contract_address_const; +// use starkremit_contract::starkremit::StarkRemit; +// use starkremit_contract::interfaces::IStarkRemit::{IStarkRemitDispatcher, +// IStarkRemitDispatcherTrait}; +// use starkremit_contract::base::types::{UserProfile, KYCLevel, RegistrationRequest}; + +// const ADMIN: felt252 = 0x123; +// const MEMBER1: felt252 = 0x789; +// const MEMBER2: felt252 = 0xABC; + +// fn setup() -> ContractAddress { +// let admin_address = contract_address_const::(); +// let contract_address = contract_address_const::<0x1>(); + +// set_caller_address(admin_address); +// set_block_timestamp(1000); + +// contract_address +// } + +// #[test] +// fn test_emergency_and_penalty_integration() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Test emergency pause +// stark_remit.emergency_pause_contract('SECURITY_BREACH'); + +// // Test member ban through emergency functions +// stark_remit.ban_member(member_address); + +// // Test penalty application (should work with emergency system) +// stark_remit.apply_late_fee(member_address, 1_u256); +// } + +// #[test] +// fn test_penalty_and_analytics_integration() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Apply penalty +// stark_remit.add_strike(member_address, 1_u256); + +// // Check that analytics are updated (if integrated properly) +// // This tests that penalty actions trigger analytics updates +// } + +// #[test] +// fn test_member_lifecycle_with_all_components() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Register member (user management) +// let registration_data = RegistrationRequest { +// email_hash: 'test@email.com', +// phone_hash: '1234567890', +// full_name: 'Test User', +// country_code: 'US', +// }; + +// set_caller_address(member_address); +// stark_remit.register_user(registration_data); + +// // Check if member is registered +// assert!(stark_remit.is_user_registered(member_address), "Member should be registered"); + +// // Apply penalties as admin +// set_caller_address(contract_address_const::()); +// stark_remit.add_strike(member_address, 1_u256); + +// // Test emergency functions +// stark_remit.emergency_reset_member_strikes(member_address); +// } + +// #[test] +// fn test_automated_scheduling_with_penalties() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Setup automated scheduling would be integrated here +// // Apply penalties that could affect scheduling +// stark_remit.add_strike(member_address, 1_u256); +// stark_remit.add_strike(member_address, 2_u256); + +// // Member should be affected in future scheduling decisions +// // (Implementation specific - depends on integration) +// } + +// #[test] +// fn test_payment_flexibility_with_analytics() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Test that payment flexibility features integrate with analytics +// // This would involve setting up auto-payments, early payments, etc. +// // and verifying that analytics track these properly +// } + +// #[test] +// fn test_comprehensive_emergency_scenario() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member1_address = contract_address_const::(); +// let member2_address = contract_address_const::(); + +// // Simulate emergency scenario +// stark_remit.emergency_pause_contract('SYSTEM_COMPROMISE'); + +// // Emergency actions should still work +// stark_remit.ban_member(member1_address); +// stark_remit.emergency_withdraw_member(member2_address); + +// // Resume operations +// stark_remit.emergency_unpause_contract(); + +// // Verify member states +// stark_remit.unban_member(member1_address); +// } + +// #[test] +// fn test_system_health_after_penalties() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Apply multiple penalties +// stark_remit.add_strike(member_address, 1_u256); +// stark_remit.apply_late_fee(member_address, 2_u256); +// stark_remit.add_strike(member_address, 3_u256); + +// // System health should be affected but still calculable +// // (This depends on the integration between analytics and penalty systems) +// } + +// #[test] +// fn test_round_completion_with_all_systems() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; + +// // Create group and round +// stark_remit.create_group(5); + +// // Test that when a round completes, all systems are updated: +// // - Analytics track the completion +// // - Penalties are applied to late members +// // - Next round is automatically scheduled +// // - Member profiles are updated + +// // This integration test verifies cross-component communication +// } + +// #[test] +// fn test_emergency_fund_recovery_integration() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let token_address = contract_address_const::<0x999>(); + +// // Test emergency token recovery +// stark_remit.emergency_recover_tokens(token_address, 1000_u256); + +// // Test emergency fund migration +// let new_contract = contract_address_const::<0x888>(); +// stark_remit.emergency_migrate_funds(new_contract); + +// // Verify that analytics are updated to reflect emergency actions +// } + +// #[test] +// fn test_multi_member_penalty_scenario() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member1_address = contract_address_const::(); +// let member2_address = contract_address_const::(); + +// // Apply different penalties to different members +// stark_remit.add_strike(member1_address, 1_u256); +// stark_remit.apply_late_fee(member2_address, 1_u256); +// stark_remit.add_strike(member1_address, 2_u256); +// stark_remit.add_strike(member1_address, 3_u256); // Should trigger ban + +// // Test that analytics properly track different member states +// // Test that emergency functions can handle banned members +// stark_remit.emergency_reset_member_strikes(member1_address); +// } + +// #[test] +// fn test_cross_component_state_consistency() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Perform actions across multiple components +// stark_remit.add_strike(member_address, 1_u256); // Penalty system +// stark_remit.ban_member(member_address); // Emergency system + +// // Verify that all systems reflect consistent state +// // Member should be banned in all relevant systems + +// // Reset and verify consistency +// stark_remit.unban_member(member_address); // Should reset penalties too +// stark_remit.emergency_reset_member_strikes(member_address); // Double-check cleanup +// } + +// #[test] +// fn test_automated_system_maintenance() { +// let contract_address = setup(); +// let stark_remit = IStarkRemitDispatcher { contract_address }; + +// // Test that automated functions work together: +// // - Schedule maintenance creates future rounds +// // - Auto-completion processes expired rounds +// // - Penalties are applied to late members +// // - Analytics track all activities + +// // Move time forward to trigger automated actions +// set_block_timestamp(10000); + +// // Trigger various automated processes +// // (Implementation depends on how components are integrated) +// } diff --git a/tests/test_penalty_component.cairo b/tests/test_penalty_component.cairo new file mode 100644 index 0000000..f4cfd13 --- /dev/null +++ b/tests/test_penalty_component.cairo @@ -0,0 +1,193 @@ +// use starknet::ContractAddress; +// use starknet::testing::{set_caller_address, set_block_timestamp}; +// use starknet::contract_address_const; +// use starkremit_contract::component::penalty::{ +// penalty_component, IPenaltyDispatcher, IPenaltyDispatcherTrait, +// PenaltyConfig, MemberPenaltyRecord +// }; + +// const ADMIN: felt252 = 0x123; +// const NON_ADMIN: felt252 = 0x456; +// const MEMBER1: felt252 = 0x789; +// const MEMBER2: felt252 = 0xABC; + +// fn setup() -> ContractAddress { +// let admin_address = contract_address_const::(); +// let contract_address = contract_address_const::<0x1>(); + +// set_caller_address(admin_address); +// set_block_timestamp(1000); + +// contract_address +// } + +// fn get_default_penalty_config() -> PenaltyConfig { +// PenaltyConfig { +// late_fee_percentage: 300, // 3% in basis points +// grace_period_hours: 24, +// max_strikes: 3, +// security_deposit_multiplier: 2, +// penalty_pool_enabled: true, +// } +// } + +// #[test] +// fn test_penalty_config() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; + +// let config = get_default_penalty_config(); + +// // Get initial config (should have default values) +// let retrieved_config = penalty.get_penalty_config(); +// assert!(retrieved_config.late_fee_percentage >= 0, "Initial config should be valid"); +// } + +// #[test] +// fn test_apply_late_fee() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); +// let round_id = 1_u256; + +// // Apply late fee +// penalty.apply_late_fee(member_address, round_id); + +// // Check member penalty record +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.total_penalties_paid > 0, "Penalty should be applied"); +// } + +// #[test] +// fn test_strike_system() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); +// let round_id = 1_u256; + +// // Add first strike +// penalty.add_strike(member_address, round_id); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.strikes == 1, "Member should have 1 strike"); +// assert!(!record.is_banned, "Member should not be banned yet"); + +// // Add second strike +// penalty.add_strike(member_address, round_id + 1); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.strikes == 2, "Member should have 2 strikes"); +// assert!(!record.is_banned, "Member should not be banned yet"); + +// // Add third strike (should result in ban if max_strikes is 3) +// penalty.add_strike(member_address, round_id + 2); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.strikes == 3, "Member should have 3 strikes"); + +// // Remove a strike +// penalty.remove_strike(member_address); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.strikes == 2, "Member should have 2 strikes after removal"); +// } + +// #[test] +// fn test_ban_unban_member() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// // Ban member +// penalty.ban_member(member_address); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.is_banned, "Member should be banned"); + +// // Unban member +// penalty.unban_member(member_address); +// let record = penalty.get_member_penalty_record(member_address); +// assert!(!record.is_banned, "Member should be unbanned"); +// assert!(record.strikes == 0, "Strikes should be reset on unban"); +// } + +// #[test] +// #[should_panic(expected: ('NOT_ADMIN',))] +// fn test_apply_late_fee_unauthorized() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let non_admin_address = contract_address_const::(); +// let member_address = contract_address_const::(); +// let round_id = 1_u256; + +// set_caller_address(non_admin_address); +// penalty.apply_late_fee(member_address, round_id); +// } + +// #[test] +// #[should_panic(expected: ('ALREADY_BANNED',))] +// fn test_ban_already_banned_member() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// penalty.ban_member(member_address); +// penalty.ban_member(member_address); // Should panic +// } + +// #[test] +// #[should_panic(expected: ('NOT_BANNED',))] +// fn test_unban_not_banned_member() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// penalty.unban_member(member_address); // Should panic as member is not banned +// } + +// #[test] +// fn test_member_penalty_record_initialization() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); + +// let record = penalty.get_member_penalty_record(member_address); +// assert!(record.strikes == 0, "New member should have 0 strikes"); +// assert!(record.total_penalties_paid == 0, "New member should have paid 0 penalties"); +// assert!(!record.is_banned, "New member should not be banned"); +// assert!(record.credit_score == 0, "New member should have initial credit score"); +// } + +// #[test] +// fn test_multiple_members_penalty_tracking() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member1_address = contract_address_const::(); +// let member2_address = contract_address_const::(); +// let round_id = 1_u256; + +// // Apply penalties to both members +// penalty.add_strike(member1_address, round_id); +// penalty.apply_late_fee(member2_address, round_id); + +// // Check that penalties are tracked separately +// let record1 = penalty.get_member_penalty_record(member1_address); +// let record2 = penalty.get_member_penalty_record(member2_address); + +// assert!(record1.strikes == 1, "Member 1 should have 1 strike"); +// assert!(record2.strikes == 0, "Member 2 should have 0 strikes"); +// assert!(record1.total_penalties_paid == 0, "Member 1 should have no late fees"); +// assert!(record2.total_penalties_paid > 0, "Member 2 should have late fees"); +// } + +// #[test] +// fn test_credit_score_updates() { +// let contract_address = setup(); +// let penalty = IPenaltyDispatcher { contract_address }; +// let member_address = contract_address_const::(); +// let round_id = 1_u256; + +// // Apply penalty and check credit score change +// let initial_record = penalty.get_member_penalty_record(member_address); +// penalty.apply_late_fee(member_address, round_id); +// let updated_record = penalty.get_member_penalty_record(member_address); + +// // Credit score should be updated (implementation may vary) +// assert!(updated_record.last_penalty_date > initial_record.last_penalty_date, "Last penalty +// date should be updated"); +// }