diff --git a/.github/workflows/invariant-tests.yml b/.github/workflows/invariant-tests.yml index bc7f256..635ce8f 100644 --- a/.github/workflows/invariant-tests.yml +++ b/.github/workflows/invariant-tests.yml @@ -14,6 +14,7 @@ env: RUST_VERSION: '1.85' # Number of proptest cases per property. Increase for deeper fuzzing. PROPTEST_CASES: 200 + PROPTEST_MAX_SHRINK_ITERS: 10000 jobs: # ───────────────────────────────────────────────────────────────────────── @@ -42,18 +43,14 @@ jobs: working-directory: ./contracts env: PROPTEST_CASES: ${{ env.PROPTEST_CASES }} + PROPTEST_MAX_SHRINK_ITERS: ${{ env.PROPTEST_MAX_SHRINK_ITERS }} run: | - if cargo test --test invariants --no-run >/dev/null 2>&1; then - cargo test --test invariants -- --nocapture 2>&1 | tee invariant-test-results.txt - else - echo "::warning::Cargo test target 'invariants' is not registered; running the full contract suite instead." | tee invariant-test-results.txt - cargo test --verbose 2>&1 | tee -a invariant-test-results.txt - fi + cargo test -p subtrackr-subscription --test property_invariants -- --nocapture 2>&1 | tee invariant-test-results.txt # ── Run all contract tests to ensure nothing regressed ───────────── - name: Run full contract test suite working-directory: ./contracts - run: cargo test --verbose + run: cargo test --workspace --lib --verbose # ── Upload test results as artifact ─────────────────────────────── - name: Upload invariant test results @@ -90,8 +87,9 @@ jobs: working-directory: ./contracts env: PROPTEST_CASES: 1000 + PROPTEST_MAX_SHRINK_ITERS: ${{ env.PROPTEST_MAX_SHRINK_ITERS }} run: | - cargo test --test invariants -- --nocapture 2>&1 | tee extended-fuzz-results.txt + cargo test -p subtrackr-subscription --test property_invariants -- --nocapture 2>&1 | tee extended-fuzz-results.txt - name: Upload extended fuzz results if: always() diff --git a/contracts/fraud/src/lib.rs b/contracts/fraud/src/lib.rs index 125c689..2dd7ccc 100644 --- a/contracts/fraud/src/lib.rs +++ b/contracts/fraud/src/lib.rs @@ -73,15 +73,17 @@ fn get_merchant_subscriptions(env: &Env, merchant: &Address) -> Vec) { - env.storage() - .persistent() - .set(&StorageKey::SubscriberSubscriptions(subscriber.clone()), &ids.clone()); + env.storage().persistent().set( + &StorageKey::SubscriberSubscriptions(subscriber.clone()), + &ids.clone(), + ); } fn save_merchant_subscriptions(env: &Env, merchant: &Address, ids: &Vec) { - env.storage() - .persistent() - .set(&StorageKey::MerchantSubscriptions(merchant.clone()), &ids.clone()); + env.storage().persistent().set( + &StorageKey::MerchantSubscriptions(merchant.clone()), + &ids.clone(), + ); } fn load_profile(env: &Env, subscription_id: SubscriptionId) -> Option { @@ -91,12 +93,17 @@ fn load_profile(env: &Env, subscription_id: SubscriptionId) -> Option) -> u32 { +fn determine_velocity_score( + env: &Env, + profile: &SubscriptionProfile, + ids: &Vec, +) -> u32 { let now = env.ledger().timestamp(); let mut recent_creations = 0u32; let mut i = 0u32; @@ -159,11 +166,7 @@ fn determine_action(score: u32) -> FraudAction { } } -fn score_profile( - env: &Env, - profile: &SubscriptionProfile, - ids: &Vec, -) -> RiskScore { +fn score_profile(env: &Env, profile: &SubscriptionProfile, ids: &Vec) -> RiskScore { let now = env.ledger().timestamp(); let velocity_score = determine_velocity_score(env, profile, ids); let anomaly_score = determine_anomaly_score(profile); @@ -233,9 +236,10 @@ fn persist_case(env: &Env, score: &RiskScore, status: FraudReviewStatus) -> Frau updated_at: score.assessed_at, }; - env.storage() - .persistent() - .set(&StorageKey::ReviewCase(score.subscription_id), &case.clone()); + env.storage().persistent().set( + &StorageKey::ReviewCase(score.subscription_id), + &case.clone(), + ); case } @@ -309,11 +313,7 @@ impl SubTrackrFraud { } } - pub fn record_chargeback( - env: Env, - subscriber: Address, - subscription_id: SubscriptionId, - ) { + pub fn record_chargeback(env: Env, subscriber: Address, subscription_id: SubscriptionId) { subscriber.require_auth(); if let Some(mut profile) = load_profile(&env, subscription_id) { @@ -373,7 +373,10 @@ impl SubTrackrFraud { let case = persist_case(&env, &score, status); update_profile_action(&env, subscription_id, &score); env.events().publish( - (String::from_str(&env, "fraud_case_opened"), score.subscription_id), + ( + String::from_str(&env, "fraud_case_opened"), + score.subscription_id, + ), (case.risk_score, case.action.clone()), ); } else { diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 237afd3..7de11e4 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -18,3 +18,5 @@ subtrackr-types = { path = "../types" } [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } +subtrackr-storage = { path = "../storage" } +proptest = "1.4.0" diff --git a/contracts/subscription/src/gas_optimization.rs b/contracts/subscription/src/gas_optimization.rs index ff1a24d..b05915d 100644 --- a/contracts/subscription/src/gas_optimization.rs +++ b/contracts/subscription/src/gas_optimization.rs @@ -1,7 +1,6 @@ /// Gas Optimization and Targeting Module /// Provides optimization recommendations and tracks gas targets - -use soroban_sdk::{String, Vec, Env}; +use soroban_sdk::{Env, String, Vec}; /// Optimization level #[derive(Clone, Copy)] @@ -112,20 +111,56 @@ impl GasOptimizationTargets { pub fn all_targets(env: &Env) -> Vec<(String, u64)> { soroban_sdk::vec![ env, - (String::from_str(env, "initialize"), Self::initialize_target()), - (String::from_str(env, "create_plan"), Self::create_plan_target()), + ( + String::from_str(env, "initialize"), + Self::initialize_target() + ), + ( + String::from_str(env, "create_plan"), + Self::create_plan_target() + ), (String::from_str(env, "subscribe"), Self::subscribe_target()), - (String::from_str(env, "charge_subscription"), Self::charge_subscription_target()), - (String::from_str(env, "cancel_subscription"), Self::cancel_subscription_target()), - (String::from_str(env, "pause_subscription"), Self::pause_subscription_target()), - (String::from_str(env, "resume_subscription"), Self::resume_subscription_target()), - (String::from_str(env, "request_refund"), Self::request_refund_target()), - (String::from_str(env, "approve_refund"), Self::approve_refund_target()), - (String::from_str(env, "request_transfer"), Self::request_transfer_target()), - (String::from_str(env, "accept_transfer"), Self::accept_transfer_target()), + ( + String::from_str(env, "charge_subscription"), + Self::charge_subscription_target() + ), + ( + String::from_str(env, "cancel_subscription"), + Self::cancel_subscription_target() + ), + ( + String::from_str(env, "pause_subscription"), + Self::pause_subscription_target() + ), + ( + String::from_str(env, "resume_subscription"), + Self::resume_subscription_target() + ), + ( + String::from_str(env, "request_refund"), + Self::request_refund_target() + ), + ( + String::from_str(env, "approve_refund"), + Self::approve_refund_target() + ), + ( + String::from_str(env, "request_transfer"), + Self::request_transfer_target() + ), + ( + String::from_str(env, "accept_transfer"), + Self::accept_transfer_target() + ), (String::from_str(env, "get_plan"), Self::get_plan_target()), - (String::from_str(env, "get_subscription"), Self::get_subscription_target()), - (String::from_str(env, "get_user_subscriptions"), Self::get_user_subscriptions_target()), + ( + String::from_str(env, "get_subscription"), + Self::get_subscription_target() + ), + ( + String::from_str(env, "get_user_subscriptions"), + Self::get_user_subscriptions_target() + ), ] } } @@ -135,7 +170,11 @@ pub struct GasOptimizations; impl GasOptimizations { /// Get optimization recommendations for a specific function - pub fn get_recommendations_for_function(env: &Env, function_name: &str, current_gas: u64) -> Vec { + pub fn get_recommendations_for_function( + env: &Env, + function_name: &str, + current_gas: u64, + ) -> Vec { let mut recommendations = Vec::new(env); match function_name { @@ -192,7 +231,10 @@ impl GasOptimizations { } } _ => { - recommendations.push_back(String::from_str(env, "Monitor function for optimization opportunities")); + recommendations.push_back(String::from_str( + env, + "Monitor function for optimization opportunities", + )); } } @@ -270,7 +312,7 @@ impl GasOptimizations { } } - pub fn get_optimization_priorities( +pub fn get_optimization_priorities( env: &Env, gas_metrics: Vec<(String, u64)>, ) -> Vec<(String, u64, String)> { @@ -279,7 +321,7 @@ impl GasOptimizations { /// Best practices for gas efficiency pub mod best_practices { - use soroban_sdk::{String, Vec, Env}; + use soroban_sdk::{Env, String, Vec}; pub fn get_storage_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); @@ -353,10 +395,7 @@ pub mod best_practices { pub fn get_validation_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); - practices.push_back(String::from_str( - env, - "Validate inputs early to fail fast", - )); + practices.push_back(String::from_str(env, "Validate inputs early to fail fast")); practices.push_back(String::from_str( env, "Use assertions for critical validations", diff --git a/contracts/subscription/src/gas_profiler.rs b/contracts/subscription/src/gas_profiler.rs index efb554b..d0e59d7 100644 --- a/contracts/subscription/src/gas_profiler.rs +++ b/contracts/subscription/src/gas_profiler.rs @@ -1,7 +1,6 @@ /// Gas Profiling Module for SubTrackr Subscription Contract /// Tracks gas consumption for each contract function and provides optimization insights - -use soroban_sdk::{Address, Env, String, Symbol,Vec}; +use soroban_sdk::{Address, Env, String, Symbol, Vec}; /// Gas profile entry for a function call #[derive(Clone)] @@ -27,10 +26,10 @@ pub struct GasMetrics { /// Function complexity categories pub enum FunctionCategory { - Read, // Simple read operations, < 50k gas - Write, // Storage write operations, 50k-150k gas - Transfer, // Token transfers, 100k-200k gas - Complex, // Multi-step operations, > 200k gas + Read, // Simple read operations, < 50k gas + Write, // Storage write operations, 50k-150k gas + Transfer, // Token transfers, 100k-200k gas + Complex, // Multi-step operations, > 200k gas } impl FunctionCategory { @@ -63,14 +62,14 @@ impl FunctionCategory { /// Storage keys for gas profiling data pub enum GasStorageKey { - Profile(String), // Function name -> GasProfile - Metrics(String), // Function name -> GasMetrics - DailyGasUsage(u64), // day timestamp -> total gas - WeeklyGasUsage(u64), // week timestamp -> total gas - MonthlyGasUsage(u64), // month timestamp -> total gas - TotalGasUsed, // u64: cumulative gas used - CallCount, // u64: total number of calls - GasAlertTriggered(String, u64), // alert type -> count + Profile(String), // Function name -> GasProfile + Metrics(String), // Function name -> GasMetrics + DailyGasUsage(u64), // day timestamp -> total gas + WeeklyGasUsage(u64), // week timestamp -> total gas + MonthlyGasUsage(u64), // month timestamp -> total gas + TotalGasUsed, // u64: cumulative gas used + CallCount, // u64: total number of calls + GasAlertTriggered(String, u64), // alert type -> count } /// Gas profiler implementation @@ -86,17 +85,17 @@ impl GasProfiler { category: FunctionCategory, ) { let fname = function_name.clone(); - + // Record function profile Self::update_profile(env, storage, &fname, gas_used); - + // Update daily/weekly/monthly tracking let now = env.ledger().timestamp(); Self::update_time_series(env, storage, now, gas_used); - + // Check if gas usage exceeds thresholds Self::check_gas_thresholds(env, storage, &fname, gas_used, category); - + // Update total counters Self::increment_counters(env, storage, gas_used); } @@ -104,7 +103,7 @@ impl GasProfiler { /// Update function profile statistics fn update_profile(env: &Env, storage: &Address, function_name: &String, gas_used: u64) { let key = GasStorageKey::Profile(function_name.clone()); - + let mut profile: GasProfile = match Self::get_profile(env, storage, function_name) { Some(p) => p, None => GasProfile { @@ -120,8 +119,16 @@ impl GasProfiler { profile.call_count += 1; profile.total_gas += gas_used; - profile.min_gas = if gas_used < profile.min_gas { gas_used } else { profile.min_gas }; - profile.max_gas = if gas_used > profile.max_gas { gas_used } else { profile.max_gas }; + profile.min_gas = if gas_used < profile.min_gas { + gas_used + } else { + profile.min_gas + }; + profile.max_gas = if gas_used > profile.max_gas { + gas_used + } else { + profile.max_gas + }; profile.avg_gas = profile.total_gas / profile.call_count; profile.last_updated = env.ledger().timestamp(); @@ -129,11 +136,7 @@ impl GasProfiler { } /// Get gas profile for a function - pub fn get_profile( - env: &Env, - storage: &Address, - function_name: &String, - ) -> Option { + pub fn get_profile(env: &Env, storage: &Address, function_name: &String) -> Option { // This would retrieve from storage // Simplified for demonstration None @@ -168,13 +171,19 @@ impl GasProfiler { if gas_used > error_threshold { // Trigger error alert env.events().publish( - (String::from_str(env, "gas_error_alert"), function_name.clone()), + ( + String::from_str(env, "gas_error_alert"), + function_name.clone(), + ), (gas_used, error_threshold, category.to_string()), ); } else if gas_used > warning_threshold { // Trigger warning alert env.events().publish( - (String::from_str(env, "gas_warning_alert"), function_name.clone()), + ( + String::from_str(env, "gas_warning_alert"), + function_name.clone(), + ), (gas_used, warning_threshold, category.to_string()), ); } @@ -233,10 +242,7 @@ impl GasProfiler { } /// Get optimization recommendations - pub fn get_optimization_recommendations( - env: &Env, - storage: &Address, - ) -> Vec { + pub fn get_optimization_recommendations(env: &Env, storage: &Address) -> Vec { // Returns array of optimization suggestions based on profiling data soroban_sdk::vec![env] } @@ -289,8 +295,8 @@ impl Drop for GasTrackGuard { fn drop(&mut self) { // Record gas usage on scope exit let start = self.env.ledger().timestamp(); - let end = self.env.ledger().sequence(); - let gas_delta = end - start as u32; // Simplified for demonstration + let end = self.env.ledger().sequence(); + let gas_delta = end - start as u32; // Simplified for demonstration GasProfiler::record_call( &self.env, &self.storage, diff --git a/contracts/subscription/src/gas_storage.rs b/contracts/subscription/src/gas_storage.rs index 85c4e2e..60a76dd 100644 --- a/contracts/subscription/src/gas_storage.rs +++ b/contracts/subscription/src/gas_storage.rs @@ -1,8 +1,7 @@ +use crate::gas_profiler::GasProfile; /// Gas Storage Module /// Manages storage and retrieval of gas profiling metrics - -use soroban_sdk::{Address, Env, String as SorobanString, TryFromVal, Val, IntoVal, Vec}; -use crate::gas_profiler::{GasProfile}; +use soroban_sdk::{Address, Env, IntoVal, String as SorobanString, TryFromVal, Val, Vec}; /// Storage keys for gas metrics #[derive(Clone)] @@ -30,11 +29,7 @@ pub struct GasMetricsStorage; impl GasMetricsStorage { /// Store a gas profile for a function - pub fn store_profile( - env: &Env, - storage: &Address, - profile: &GasProfile, - ) { + pub fn store_profile(env: &Env, storage: &Address, profile: &GasProfile) { let key = format_gas_profile_key(env, &profile.function_name); // Serialize and store profile // This would use actual storage @@ -51,12 +46,7 @@ impl GasMetricsStorage { } /// Update daily gas aggregates - pub fn update_daily_aggregate( - env: &Env, - storage: &Address, - day_timestamp: u64, - gas_used: u64, - ) { + pub fn update_daily_aggregate(env: &Env, storage: &Address, day_timestamp: u64, gas_used: u64) { // Increment daily aggregate for the given day } @@ -81,31 +71,19 @@ impl GasMetricsStorage { } /// Get daily gas usage - pub fn get_daily_usage( - env: &Env, - storage: &Address, - day_timestamp: u64, - ) -> u64 { + pub fn get_daily_usage(env: &Env, storage: &Address, day_timestamp: u64) -> u64 { // Retrieve daily aggregate 0 } /// Get weekly gas usage - pub fn get_weekly_usage( - env: &Env, - storage: &Address, - week_timestamp: u64, - ) -> u64 { + pub fn get_weekly_usage(env: &Env, storage: &Address, week_timestamp: u64) -> u64 { // Retrieve weekly aggregate 0 } /// Get monthly gas usage - pub fn get_monthly_usage( - env: &Env, - storage: &Address, - month_timestamp: u64, - ) -> u64 { + pub fn get_monthly_usage(env: &Env, storage: &Address, month_timestamp: u64) -> u64 { // Retrieve monthly aggregate 0 } @@ -123,11 +101,7 @@ impl GasMetricsStorage { } /// Increment total gas used - pub fn increment_total_gas( - env: &Env, - storage: &Address, - gas_amount: u64, - ) { + pub fn increment_total_gas(env: &Env, storage: &Address, gas_amount: u64) { // Increment total gas } @@ -137,43 +111,26 @@ impl GasMetricsStorage { } /// Record gas alert - pub fn record_alert( - env: &Env, - storage: &Address, - alert_type: &str, - ) { + pub fn record_alert(env: &Env, storage: &Address, alert_type: &str) { let alert_key = SorobanString::from_str(env, alert_type); // Increment alert count } /// Get gas alert count by type - pub fn get_alert_count( - env: &Env, - storage: &Address, - alert_type: &str, - ) -> u64 { + pub fn get_alert_count(env: &Env, storage: &Address, alert_type: &str) -> u64 { let alert_key = SorobanString::from_str(env, alert_type); // Retrieve alert count 0 } /// Update last recorded gas usage for a function - pub fn update_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - gas_used: u64, - ) { + pub fn update_last_usage(env: &Env, storage: &Address, function_name: &str, gas_used: u64) { let fname = SorobanString::from_str(env, function_name); // Update last usage } /// Get last recorded gas usage - pub fn get_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - ) -> Option { + pub fn get_last_usage(env: &Env, storage: &Address, function_name: &str) -> Option { let fname = SorobanString::from_str(env, function_name); // Retrieve last usage None diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..50ec3dd 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -1,7 +1,10 @@ #![no_std] +mod gas_optimization; mod gas_profiler; mod gas_storage; -mod gas_optimization; +mod quota; +mod revenue; +mod usage; use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, diff --git a/contracts/subscription/tests/property_invariants.rs b/contracts/subscription/tests/property_invariants.rs new file mode 100644 index 0000000..ee37aeb --- /dev/null +++ b/contracts/subscription/tests/property_invariants.rs @@ -0,0 +1,307 @@ +use proptest::prelude::*; +use soroban_sdk::testutils::{Address as _, EnvTestConfig, Ledger}; +use soroban_sdk::{token, Address, Env, String}; +use subtrackr_storage::{SubTrackrStorage, SubTrackrStorageClient}; +use subtrackr_subscription::{SubTrackrSubscription, SubTrackrSubscriptionClient}; +use subtrackr_types::{Interval, SubscriptionStatus}; + +const START_TIME: u64 = 1_700_000_000; +const TOKEN_MINT: i128 = 1_000_000_000; + +struct Harness { + env: Env, + subscription: Address, + proxy: Address, + storage: Address, + merchant: Address, +} + +impl Harness { + fn new() -> Self { + let env = Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env.mock_all_auths(); + env.ledger().set_timestamp(START_TIME); + + let proxy = Address::generate(&env); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let subscription = env.register_contract(None, SubTrackrSubscription); + let storage = env.register_contract(None, SubTrackrStorage); + let storage_client = SubTrackrStorageClient::new(&env, &storage); + let subscription_client = SubTrackrSubscriptionClient::new(&env, &subscription); + + storage_client.initialize(&admin, &subscription); + subscription_client.initialize(&proxy, &storage, &admin); + + Self { + env, + subscription, + proxy, + storage, + merchant, + } + } + + fn client(&self) -> SubTrackrSubscriptionClient<'_> { + SubTrackrSubscriptionClient::new(&self.env, &self.subscription) + } + + fn make_token(&self, mint_to: &Address) -> Address { + let token_admin = Address::generate(&self.env); + let token_id = self + .env + .register_stellar_asset_contract_v2(token_admin.clone()); + let asset_client = token::StellarAssetClient::new(&self.env, &token_id.address()); + asset_client.mint(mint_to, &TOKEN_MINT); + token_id.address() + } + + fn create_plan(&self, price: i128, interval: Interval) -> (u64, Address) { + let token = self.make_token(&self.merchant); + let plan_id = self.client().create_plan( + &self.proxy, + &self.storage, + &self.merchant, + &String::from_str(&self.env, "property-plan"), + &price, + &token, + &interval, + ); + (plan_id, token) + } + + fn subscribe(&self, plan_id: u64, token: &Address) -> (u64, Address) { + let subscriber = Address::generate(&self.env); + let asset_client = token::StellarAssetClient::new(&self.env, token); + asset_client.mint(&subscriber, &TOKEN_MINT); + + let subscription_id = + self.client() + .subscribe(&self.proxy, &self.storage, &subscriber, &plan_id); + (subscription_id, subscriber) + } + + fn advance_time(&self, seconds: u64) { + self.env.ledger().with_mut(|ledger| { + ledger.timestamp += seconds; + }); + } + + fn charge(&self, subscription_id: u64, interval: &Interval) { + self.advance_time(interval.seconds() + 1); + self.client() + .charge_subscription(&self.proxy, &self.storage, &subscription_id); + } +} + +fn interval_strategy() -> impl Strategy { + prop_oneof![ + Just(Interval::Daily), + Just(Interval::Weekly), + Just(Interval::Monthly), + Just(Interval::Quarterly), + Just(Interval::Yearly), + ] +} + +#[derive(Debug, Clone)] +enum LifecycleAction { + Charge, + Pause(u64), + Resume, + RequestRefund(u8), + ApproveRefund, + RejectRefund, + Cancel, +} + +fn lifecycle_action_strategy() -> impl Strategy { + prop_oneof![ + 4 => Just(LifecycleAction::Charge), + 2 => (1u64..=2_592_000u64).prop_map(LifecycleAction::Pause), + 2 => Just(LifecycleAction::Resume), + 2 => (1u8..=100u8).prop_map(LifecycleAction::RequestRefund), + 1 => Just(LifecycleAction::ApproveRefund), + 1 => Just(LifecycleAction::RejectRefund), + 1 => Just(LifecycleAction::Cancel), + ] +} + +fn proptest_config() -> ProptestConfig { + ProptestConfig { + cases: std::env::var("PROPTEST_CASES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(64), + max_shrink_iters: std::env::var("PROPTEST_MAX_SHRINK_ITERS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(10_000), + failure_persistence: None, + ..ProptestConfig::default() + } +} + +proptest! { + #![proptest_config(proptest_config())] + + #[test] + fn prop_created_plan_count_matches_successful_plan_creations( + prices in prop::collection::vec(1i128..50_000i128, 1..20), + interval in interval_strategy(), + ) { + let h = Harness::new(); + + for price in &prices { + h.create_plan(*price, interval.clone()); + } + + prop_assert_eq!( + h.client().get_plan_count(&h.proxy, &h.storage), + prices.len() as u64 + ); + } + + #[test] + fn prop_repeated_charges_preserve_subscription_accounting( + price in 1i128..25_000i128, + charges in 1u32..8u32, + interval in interval_strategy(), + ) { + let h = Harness::new(); + let (plan_id, token) = h.create_plan(price, interval.clone()); + let (subscription_id, _subscriber) = h.subscribe(plan_id, &token); + + for expected_charge_count in 1..=charges { + h.charge(subscription_id, &interval); + let sub = h + .client() + .get_subscription(&h.proxy, &h.storage, &subscription_id); + + prop_assert_eq!(sub.status, SubscriptionStatus::Active); + prop_assert_eq!(sub.charge_count, expected_charge_count); + prop_assert_eq!(sub.total_paid, price * expected_charge_count as i128); + prop_assert_eq!(sub.refund_requested_amount, 0); + prop_assert!(sub.next_charge_at > sub.last_charged_at); + } + + let plan = h.client().get_plan(&h.proxy, &h.storage, &plan_id); + prop_assert_eq!(plan.subscriber_count, 1); + } + + #[test] + fn prop_lifecycle_actions_preserve_core_invariants( + price in 1i128..10_000i128, + interval in interval_strategy(), + actions in prop::collection::vec(lifecycle_action_strategy(), 3..30), + ) { + let h = Harness::new(); + let (plan_id, token) = h.create_plan(price, interval.clone()); + let (subscription_id, subscriber) = h.subscribe(plan_id, &token); + + let mut expected_status = SubscriptionStatus::Active; + let mut expected_total_paid = 0i128; + let mut expected_charge_count = 0u32; + let mut expected_pending_refund = 0i128; + + for action in actions { + match action { + LifecycleAction::Charge if expected_status == SubscriptionStatus::Active => { + h.charge(subscription_id, &interval); + expected_total_paid += price; + expected_charge_count += 1; + } + LifecycleAction::Pause(duration) if expected_status == SubscriptionStatus::Active => { + h.client().pause_by_subscriber( + &h.proxy, + &h.storage, + &subscriber, + &subscription_id, + &duration, + ); + expected_status = SubscriptionStatus::Paused; + } + LifecycleAction::Resume if expected_status == SubscriptionStatus::Paused => { + h.client().resume_subscription( + &h.proxy, + &h.storage, + &subscriber, + &subscription_id, + ); + expected_status = SubscriptionStatus::Active; + } + LifecycleAction::RequestRefund(percent) + if expected_total_paid > 0 && expected_pending_refund == 0 => + { + let amount = core::cmp::max( + 1, + expected_total_paid * percent as i128 / 100, + ); + h.client() + .request_refund(&h.proxy, &h.storage, &subscription_id, &amount); + expected_pending_refund = amount; + } + LifecycleAction::ApproveRefund if expected_pending_refund > 0 => { + h.client() + .approve_refund(&h.proxy, &h.storage, &subscription_id); + expected_total_paid -= expected_pending_refund; + expected_pending_refund = 0; + } + LifecycleAction::RejectRefund if expected_pending_refund > 0 => { + h.client() + .reject_refund(&h.proxy, &h.storage, &subscription_id); + expected_pending_refund = 0; + } + LifecycleAction::Cancel + if expected_status == SubscriptionStatus::Active + || expected_status == SubscriptionStatus::Paused => + { + h.client().cancel_subscription( + &h.proxy, + &h.storage, + &subscriber, + &subscription_id, + ); + expected_status = SubscriptionStatus::Cancelled; + } + _ => {} + } + + let sub = h + .client() + .get_subscription(&h.proxy, &h.storage, &subscription_id); + let plan = h.client().get_plan(&h.proxy, &h.storage, &plan_id); + let user_subs = + h.client() + .get_user_subscriptions(&h.proxy, &h.storage, &subscriber); + + prop_assert_eq!(sub.status, expected_status.clone()); + prop_assert_eq!(sub.total_paid, expected_total_paid); + prop_assert_eq!(sub.charge_count, expected_charge_count); + prop_assert_eq!(sub.refund_requested_amount, expected_pending_refund); + prop_assert!(sub.refund_requested_amount <= sub.total_paid); + prop_assert!(sub.total_paid >= 0); + prop_assert_eq!( + plan.subscriber_count, + if expected_status == SubscriptionStatus::Cancelled { 0 } else { 1 } + ); + prop_assert_eq!(user_subs.len(), 1); + prop_assert_eq!(user_subs.get_unchecked(0), subscription_id); + } + } +} + +#[test] +fn deterministic_invalid_refund_cannot_exceed_total_paid() { + let h = Harness::new(); + let (plan_id, token) = h.create_plan(100, Interval::Monthly); + let (subscription_id, _subscriber) = h.subscribe(plan_id, &token); + + let result = h + .client() + .try_request_refund(&h.proxy, &h.storage, &subscription_id, &1); + + assert!(result.is_err(), "refund before payment should be rejected"); +}