diff --git a/contracts/grant_contracts/src/lib.rs b/contracts/grant_contracts/src/lib.rs index effdf02..6dedaf6 100644 --- a/contracts/grant_contracts/src/lib.rs +++ b/contracts/grant_contracts/src/lib.rs @@ -59,6 +59,8 @@ const MAX_EVIDENCE_LENGTH: u32 = 2000; // Maximum evidence string length pub mod temporal_guard; pub mod stream_nft; pub mod multi_token_matching; +pub mod staking_multiplier; +pub mod governance; pub mod sub_dao_authority; pub mod grant_appeals; pub mod wasm_hash_verification; @@ -860,6 +862,16 @@ pub enum DataKey { // Grant Registry keys for on-chain indexing GrantRegistry(Address), // Maps landlord (lessor) address to array of grant contract hashes + // Task #192: Batch refund tracking + DonorRecord(u64, Address), // Maps grant_id + donor to contribution amount + GrantDonors(u64), // List of donors for a grant + + // Task #189: Conditional Matching Multiplier based on Staking + StakingMultiplierContract, // Address of the Staking Multiplier contract + VestingVaultContract, // Address of the Vesting Vault contract + StakingWeights(Address), // Maps donor to staking weights + ProjectTokenSupport(Address), // Maps project token to supported status + StakingCache(Address, Address), // Maps donor + project to cached staking info } #[contracterror] diff --git a/contracts/grant_contracts/src/staking_multiplier.rs b/contracts/grant_contracts/src/staking_multiplier.rs new file mode 100644 index 0000000..adbf8b7 --- /dev/null +++ b/contracts/grant_contracts/src/staking_multiplier.rs @@ -0,0 +1,482 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Map, Vec, + token, Symbol, IntoVal, i128, u64, +}; + +// --- Constants --- +const STAKING_WEIGHT_PRECISION: u128 = 1_000_000; // 1e6 for weight calculations +const BASE_MATCHING_WEIGHT: u128 = 1_000_000; // Base weight (1.0x) +const MAX_BOOST_MULTIPLIER: u128 = 3_000_000; // Maximum 3x boost +const MIN_STAKE_FOR_BOOST: u128 = 100_000; // Minimum stake to get any boost +const BOOST_TIER_1_THRESHOLD: u128 = 100_000; // 0.1 tokens for 1.2x boost +const BOOST_TIER_2_THRESHOLD: u128 = 1_000_000; // 1 token for 1.5x boost +const BOOST_TIER_3_THRESHOLD: u128 = 10_000_000; // 10 tokens for 2.0x boost +const BOOST_TIER_4_THRESHOLD: u128 = 100_000_000; // 100 tokens for 3.0x boost +const VESTING_QUERY_TIMEOUT: u64 = 30; // 30 seconds timeout for vesting queries +const MAX_STAKING_WEIGHT_BPS: u32 = 20000; // Maximum 200% weight (2x base) + +// --- Types --- + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum StakingMultiplierError { + NotInitialized = 1, + Unauthorized = 2, + VestingVaultNotFound = 3, + VestingQueryFailed = 4, + InvalidStakeAmount = 5, + StakeTooLow = 6, + StakeTooHigh = 7, + InvalidProjectToken = 8, + MathOverflow = 9, + InsufficientMatchingPool = 10, + InvalidMultiplier = 11, + QueryTimeout = 12, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct StakingInfo { + pub donor: Address, + pub project_token: Address, + pub staked_amount: u128, + pub vesting_end_time: u64, + pub lock_duration: u64, + pub weight_multiplier: u128, // Weight multiplier in basis points (10000 = 1.0x) + pub last_updated: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct WeightedDonation { + pub base_donation: Donation, + pub staking_weight: u128, + pub weighted_amount: u128, + pub boost_multiplier: u128, // Actual multiplier applied (e.g., 1200000 for 1.2x) + pub staking_info: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct Donation { + pub donor: Address, + pub token_address: Address, + pub amount: u128, + pub normalized_value: u128, // Value in native token units + pub timestamp: u64, + pub round_id: u64, + pub project_id: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct WeightedProjectDonations { + pub project_id: u64, + pub base_total: u128, // Total without weighting + pub weighted_total: u128, // Total with staking weights applied + pub unique_donors: u32, + pub weighted_donations: Vec, + pub matching_amount: u128, + pub final_payout: u128, + pub total_boost_applied: u128, // Total boost multiplier applied +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct VestingVaultQuery { + pub donor: Address, + pub project_token: Address, + pub query_timestamp: u64, + pub response_timestamp: Option, + pub staked_amount: Option, + pub vesting_end_time: Option, + pub status: QueryStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum QueryStatus { + Pending, + Success, + Failed, + Timeout, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum StakingMultiplierDataKey { + Admin, + VestingVaultContract, + NextQueryId, + VestingQuery(u64), // query_id -> VestingVaultQuery + DonorStakeCache(Address, Address), // donor + project_token -> StakingInfo + ProjectToken(Address), // project_token -> bool (supported) + StakingWeights(Address), // donor -> Map + CacheExpiry(u64), // timestamp for cache expiry +} + +#[contract] +pub struct StakingMultiplierContract; + +#[contractimpl] +impl StakingMultiplierContract { + /// Initialize the staking multiplier contract + pub fn initialize( + env: Env, + admin: Address, + vesting_vault_contract: Address, + ) -> Result<(), StakingMultiplierError> { + if env.storage().instance().has(&StakingMultiplierDataKey::Admin) { + return Err(StakingMultiplierError::NotInitialized); + } + + env.storage().instance().set(&StakingMultiplierDataKey::Admin, &admin); + env.storage().instance().set(&StakingMultiplierDataKey::VestingVaultContract, &vesting_vault_contract); + env.storage().instance().set(&StakingMultiplierDataKey::NextQueryId, &1u64); + + env.events().publish( + (symbol_short!("staking_mult_initialized"),), + (admin, vesting_vault_contract), + ); + + Ok(()) + } + + /// Add support for a project token + pub fn add_project_token( + env: Env, + admin: Address, + project_token: Address, + ) -> Result<(), StakingMultiplierError> { + Self::require_admin_auth(&env, &admin)?; + + env.storage().instance().set(&StakingMultiplierDataKey::ProjectToken(project_token), &true); + + env.events().publish( + (symbol_short!("project_token_added"),), + (project_token,), + ); + + Ok(()) + } + + /// Query vesting vault for donor's staking information + pub fn query_vesting_stake( + env: Env, + donor: Address, + project_token: Address, + ) -> Result { + // Check if project token is supported + if !env.storage().instance().has(&StakingMultiplierDataKey::ProjectToken(project_token.clone())) { + return Err(StakingMultiplierError::InvalidProjectToken); + } + + // Check cache first + if let Some(cached_info) = Self::get_cached_staking_info(&env, &donor, &project_token)? { + let now = env.ledger().timestamp(); + let cache_expiry = env.storage().instance().get(&StakingMultiplierDataKey::CacheExpiry).unwrap_or(0); + + if now < cache_expiry { + // Cache is still valid + return Ok(cached_info.weight_multiplier); + } + } + + // Create new query + let query_id = env.storage() + .instance() + .get(&StakingMultiplierDataKey::NextQueryId) + .unwrap_or(1u64); + + let next_id = query_id + 1; + env.storage().instance().set(&StakingMultiplierDataKey::NextQueryId, &next_id); + + let query = VestingVaultQuery { + donor: donor.clone(), + project_token: project_token.clone(), + query_timestamp: env.ledger().timestamp(), + response_timestamp: None, + staked_amount: None, + vesting_end_time: None, + status: QueryStatus::Pending, + }; + + env.storage().instance().set(&StakingMultiplierDataKey::VestingQuery(query_id), &query); + + // In a real implementation, this would make an inter-contract call to the vesting vault + // For now, we'll simulate the response + let weight_multiplier = Self::simulate_vesting_query(&env, &donor, &project_token)?; + + // Update query with response + let mut updated_query = query; + updated_query.response_timestamp = Some(env.ledger().timestamp()); + updated_query.status = QueryStatus::Success; + updated_query.staked_amount = Some(weight_multiplier); + env.storage().instance().set(&StakingMultiplierDataKey::VestingQuery(query_id), &updated_query); + + // Cache the result + let staking_info = StakingInfo { + donor: donor.clone(), + project_token: project_token.clone(), + staked_amount: weight_multiplier, + vesting_end_time: env.ledger().timestamp() + 86400 * 30, // 30 days + lock_duration: 86400 * 30, + weight_multiplier: Self::calculate_boost_multiplier(weight_multiplier), + last_updated: env.ledger().timestamp(), + }; + + Self::cache_staking_info(&env, &donor, &project_token, &staking_info)?; + + // Set cache expiry (24 hours) + env.storage().instance().set(&StakingMultiplierDataKey::CacheExpiry, &(env.ledger().timestamp() + 86400)); + + env.events().publish( + (symbol_short!("vesting_queried"),), + (donor, project_token, weight_multiplier), + ); + + Ok(staking_info.weight_multiplier) + } + + /// Apply staking weights to donations + pub fn apply_staking_weights( + env: Env, + donations: Vec, + project_token: Address, + ) -> Result, StakingMultiplierError> { + let mut weighted_donations = Vec::new(&env); + let mut total_boost_applied = 0u128; + + for donation in donations.iter() { + let staking_weight = Self::get_donor_staking_weight(&env, &donation.donor, &project_token)?; + let boost_multiplier = Self::calculate_boost_multiplier(staking_weight); + + let weighted_amount = donation.normalized_value + .checked_mul(staking_weight) + .ok_or(StakingMultiplierError::MathOverflow)? + .checked_div(STAKING_WEIGHT_PRECISION) + .ok_or(StakingMultiplierError::MathOverflow)?; + + let weighted_donation = WeightedDonation { + base_donation: donation.clone(), + staking_weight, + weighted_amount, + boost_multiplier, + staking_info: Self::get_cached_staking_info(&env, &donation.donor, &project_token)?, + }; + + total_boost_applied += boost_multiplier; + weighted_donations.push_back(weighted_donation); + } + + env.events().publish( + (symbol_short!("weights_applied"),), + (project_token, weighted_donations.len(), total_boost_applied), + ); + + Ok(weighted_donations) + } + + /// Calculate weighted quadratic funding + pub fn calculate_weighted_matching( + env: Env, + project_donations: &Map, + matching_pool: u128, + quadratic_coefficient: u128, + ) -> Result, StakingMultiplierError> { + let mut results = Map::new(&env); + let mut total_weighted_sqrt_sum = 0u128; + + // First pass: calculate weighted square roots + for (project_id, weighted_donations) in project_donations.iter() { + if weighted_donations.weighted_total > 0 { + let sqrt_value = Self::integer_square_root( + weighted_donations.weighted_total * QUADRATIC_PRECISION + )?; + total_weighted_sqrt_sum += sqrt_value; + } + } + + if total_weighted_sqrt_sum == 0 { + return Ok(results); + } + + // Second pass: calculate matching amounts with solvency protection + let mut total_matching_allocated = 0u128; + + for (project_id, weighted_donations) in project_donations.iter() { + if weighted_donations.weighted_total > 0 { + let sqrt_value = Self::integer_square_root( + weighted_donations.weighted_total * QUADRATIC_PRECISION + )?; + + let matching_amount = (sqrt_value * matching_pool * quadratic_coefficient) + .checked_div(total_weighted_sqrt_sum) + .ok_or(StakingMultiplierError::MathOverflow)? + .checked_div(QUADRATIC_PRECISION) + .ok_or(StakingMultiplierError::MathOverflow)?; + + // Solvency protection + if total_matching_allocated + matching_amount > matching_pool { + let remaining_pool = matching_pool - total_matching_allocated; + if remaining_pool > 0 { + results.set(*project_id, remaining_pool); + total_matching_allocated += remaining_pool; + } + break; + } + + results.set(*project_id, matching_amount); + total_matching_allocated += matching_amount; + } + } + + env.events().publish( + (symbol_short!("weighted_matching_calculated"),), + (matching_pool, total_matching_allocated, results.len()), + ); + + Ok(results) + } + + /// Get donor's staking weight for a project + pub fn get_donor_staking_weight( + env: &Env, + donor: &Address, + project_token: &Address, + ) -> Result { + // Check cache first + if let Some(cached_info) = Self::get_cached_staking_info(env, donor, project_token)? { + let now = env.ledger().timestamp(); + let cache_expiry = env.storage().instance().get(&StakingMultiplierDataKey::CacheExpiry).unwrap_or(0); + + if now < cache_expiry { + return Ok(cached_info.weight_multiplier); + } + } + + // Query vesting vault if not cached + Self::query_vesting_stake(env.clone(), donor.clone(), project_token.clone()) + } + + /// Get cached staking information + pub fn get_cached_staking_info( + env: &Env, + donor: &Address, + project_token: &Address, + ) -> Result, StakingMultiplierError> { + env.storage() + .instance() + .get(&StakingMultiplierDataKey::DonorStakeCache(donor.clone(), project_token.clone())) + } + + /// Clear expired cache entries + pub fn clear_expired_cache(env: Env) -> Result { + let now = env.ledger().timestamp(); + let cache_expiry = env.storage().instance().get(&StakingMultiplierDataKey::CacheExpiry).unwrap_or(0); + + if now < cache_expiry { + return Ok(0); // Cache is still valid + } + + // In a real implementation, this would iterate through cache entries + // For now, we'll just reset the expiry + env.storage().instance().remove(&StakingMultiplierDataKey::CacheExpiry); + + Ok(1) // Return number of cleared entries + } + + // --- Helper Functions --- + + fn require_admin_auth(env: &Env, admin: &Address) -> Result<(), StakingMultiplierError> { + let stored_admin: Address = env.storage() + .instance() + .get(&StakingMultiplierDataKey::Admin) + .ok_or(StakingMultiplierError::NotInitialized)?; + + if stored_admin != *admin { + return Err(StakingMultiplierError::Unauthorized); + } + + admin.require_auth(); + Ok(()) + } + + fn get_vesting_vault_contract(env: &Env) -> Result { + env.storage() + .instance() + .get(&StakingMultiplierDataKey::VestingVaultContract) + .ok_or(StakingMultiplierError::VestingVaultNotFound) + } + + fn calculate_boost_multiplier(staked_amount: u128) -> u128 { + if staked_amount < MIN_STAKE_FOR_BOOST { + return BASE_MATCHING_WEIGHT; // No boost + } + + let multiplier = if staked_amount >= BOOST_TIER_4_THRESHOLD { + MAX_BOOST_MULTIPLIER // 3x boost + } else if staked_amount >= BOOST_TIER_3_THRESHOLD { + 2_000_000 // 2x boost + } else if staked_amount >= BOOST_TIER_2_THRESHOLD { + 1_500_000 // 1.5x boost + } else if staked_amount >= BOOST_TIER_1_THRESHOLD { + 1_200_000 // 1.2x boost + } else { + BASE_MATCHING_WEIGHT // 1x boost (base case) + }; + + multiplier + } + + fn cache_staking_info( + env: &Env, + donor: &Address, + project_token: &Address, + staking_info: &StakingInfo, + ) -> Result<(), StakingMultiplierError> { + env.storage().instance().set( + &StakingMultiplierDataKey::DonorStakeCache(donor.clone(), project_token.clone()), + staking_info, + ); + Ok(()) + } + + fn simulate_vesting_query( + env: &Env, + donor: &Address, + project_token: &Address, + ) -> Result { + // Simulate vesting vault response + // In a real implementation, this would query the actual vesting vault contract + let now = env.ledger().timestamp(); + + // Simulate different stake amounts based on donor address (for testing) + let donor_bytes = donor.to_fixed_bytes(); + let simulated_amount = u128::from(donor_bytes[0]) * 10000 + u128::from(donor_bytes[1]) * 1000; + + Ok(simulated_amount) + } + + fn integer_square_root(n: u128) -> Result { + if n == 0 { + return Ok(0); + } + + let mut x = n; + let mut y = (x + 1) / 2; + + while y < x { + x = y; + y = (x + n / x) / 2; + } + + Ok(x) + } +} + +// --- Supporting Types --- + +const QUADRATIC_PRECISION: u128 = 1_000_000; // 1e6 for quadratic calculations diff --git a/contracts/grant_contracts/src/test_staking_multiplier.rs b/contracts/grant_contracts/src/test_staking_multiplier.rs new file mode 100644 index 0000000..0b387bc --- /dev/null +++ b/contracts/grant_contracts/src/test_staking_multiplier.rs @@ -0,0 +1,544 @@ +#![cfg(test)] + +use soroban_sdk::{symbol_short, Address, Env, Vec, Map, u128}; +use crate::staking_multiplier::{ + StakingMultiplierContract, StakingMultiplierContractClient, StakingMultiplierError, StakingMultiplierDataKey, + StakingInfo, WeightedDonation, WeightedProjectDonations, VestingVaultQuery, QueryStatus, + STAKING_WEIGHT_PRECISION, BASE_MATCHING_WEIGHT, MAX_BOOST_MULTIPLIER, MIN_STAKE_FOR_BOOST, + BOOST_TIER_1_THRESHOLD, BOOST_TIER_2_THRESHOLD, BOOST_TIER_3_THRESHOLD, BOOST_TIER_4_THRESHOLD +}; + +#[test] +fn test_staking_multiplier_initialization() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + // Test successful initialization + client.initialize(&admin, &vesting_vault); + + // Verify admin is set + let stored_admin = env.storage().instance().get(&StakingMultiplierDataKey::Admin).unwrap(); + assert_eq!(stored_admin, admin); + + // Verify vesting vault is set + let stored_vault = env.storage().instance().get(&StakingMultiplierDataKey::VestingVaultContract).unwrap(); + assert_eq!(stored_vault, vesting_vault); + + // Verify next query ID is initialized + let next_query_id = env.storage().instance().get(&StakingMultiplierDataKey::NextQueryId).unwrap(); + assert_eq!(next_query_id, 1); +} + +#[test] +fn test_staking_multiplier_double_initialization() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + // First initialization should succeed + client.initialize(&admin, &vesting_vault); + + // Second initialization should fail + let result = client.try_initialize(&admin, &vesting_vault); + assert_eq!(result, Err(StakingMultiplierError::NotInitialized)); +} + +#[test] +fn test_add_project_token() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let project_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + + // Add project token + client.add_project_token(&admin, &project_token); + + // Verify project token is supported + let is_supported = env.storage().instance().get(&StakingMultiplierDataKey::ProjectToken(project_token)).unwrap(); + assert!(is_supported); +} + +#[test] +fn test_query_vesting_stake_with_cache() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor = Address::generate(&env); + let project_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // First query should hit vesting vault + let weight1 = client.query_vesting_stake(&donor, &project_token); + assert!(weight1.is_ok()); + + // Second query should use cache + let weight2 = client.query_vesting_stake(&donor, &project_token); + assert!(weight2.is_ok()); + assert_eq!(weight1.unwrap(), weight2.unwrap()); + + // Verify cache was set + let cached_info = client.get_cached_staking_info(&donor, &project_token); + assert!(cached_info.is_ok()); + assert!(cached_info.unwrap().is_some()); +} + +#[test] +fn test_query_vesting_stake_unsupported_token() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor = Address::generate(&env); + let unsupported_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + + // Try query with unsupported token + let result = client.try_query_vesting_stake(&donor, &unsupported_token); + assert_eq!(result, Err(StakingMultiplierError::InvalidProjectToken)); +} + +#[test] +fn test_apply_staking_weights() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor1 = Address::generate(&env); + let donor2 = Address::generate(&env); + let project_token = Address::generate(&env); + let usdc = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // Create test donations + let donation1 = crate::multi_token_matching::Donation { + donor: donor1.clone(), + token_address: usdc, + amount: 10000u128, + normalized_value: 10000000000000u128, // 10,000 * 1,000,000 precision + timestamp: env.ledger().timestamp(), + round_id: 1, + project_id: 123, + }; + + let donation2 = crate::multi_token_matching::Donation { + donor: donor2.clone(), + token_address: usdc, + amount: 5000u128, + normalized_value: 5000000000000u128, // 5,000 * 1,000,000 precision + timestamp: env.ledger().timestamp(), + round_id: 1, + project_id: 123, + }; + + let donations = Vec::from_array(&env, [donation1, donation2]); + + // Apply staking weights + let weighted_donations = client.apply_staking_weights(donations, project_token); + assert!(weighted_donations.is_ok()); + + let weighted = weighted_donations.unwrap(); + assert_eq!(weighted.len(), 2); + + // Verify weights were applied + for weighted_donation in weighted.iter() { + assert!(weighted_donation.weighted_amount >= weighted_donation.base_donation.normalized_value); + assert!(weighted_donation.boost_multiplier >= BASE_MATCHING_WEIGHT); + } +} + +#[test] +fn test_calculate_boost_multiplier() { + // Test different stake amounts and their corresponding boost multipliers + let test_cases = vec![ + (0u128, BASE_MATCHING_WEIGHT), // 0 stake = 1x boost + (50000u128, BASE_MATCHING_WEIGHT), // Below minimum = 1x boost + (100000u128, 1200000u128), // Tier 1 = 1.2x boost + (500000u128, 1200000u128), // Tier 1 = 1.2x boost + (1000000u128, 1500000u128), // Tier 2 = 1.5x boost + (5000000u128, 1500000u128), // Tier 2 = 1.5x boost + (10000000u128, 2000000u128), // Tier 3 = 2.0x boost + (50000000u128, 2000000u128), // Tier 3 = 2.0x boost + (100000000u128, 3000000u128), // Tier 4 = 3.0x boost (max) + (500000000u128, 3000000u128), // Tier 4 = 3.0x boost (max) + ]; + + for (stake_amount, expected_multiplier) in test_cases { + let multiplier = StakingMultiplierContract::calculate_boost_multiplier(stake_amount); + assert_eq!(multiplier, expected_multiplier, "Failed for stake amount: {}", stake_amount); + } +} + +#[test] +fn test_weighted_quadratic_matching() { + let env = Env::default(); + + // Create test weighted project donations + let project1_donations = WeightedProjectDonations { + project_id: 1, + base_total: 1000000u128, // Base donations: 1M + weighted_total: 1500000u128, // Weighted donations: 1.5M (due to staking) + unique_donors: 3, + weighted_donations: Vec::new(&env), + matching_amount: 0, + final_payout: 0, + total_boost_applied: 1500000u128, // 1.5x total boost + }; + + let project2_donations = WeightedProjectDonations { + project_id: 2, + base_total: 500000u128, // Base donations: 0.5M + weighted_total: 500000u128, // Weighted donations: 0.5M (no staking) + unique_donors: 2, + weighted_donations: Vec::new(&env), + matching_amount: 0, + final_payout: 0, + total_boost_applied: 1000000u128, // 1.0x total boost + }; + + let project_donations = Map::from_array(&env, [ + (1u64, project1_donations), + (2u64, project2_donations), + ]); + + let matching_pool = 1000000u128; // 1M matching pool + let quadratic_coefficient = 1000000u128; // 1.0 coefficient + + // Calculate weighted matching + let results = StakingMultiplierContract::calculate_weighted_matching( + &env, + &project_donations, + matching_pool, + quadratic_coefficient, + ); + + assert!(results.is_ok()); + + let matching_results = results.unwrap(); + assert_eq!(matching_results.len(), 2); + + // Project 1 should get more matching due to higher weighted total + let project1_matching = matching_results.get(1u64).unwrap(); + let project2_matching = matching_results.get(2u64).unwrap(); + + assert!(project1_matching > project2_matching); + + // Total matching should not exceed pool + let total_matching = project1_matching + project2_matching; + assert!(total_matching <= matching_pool); +} + +#[test] +fn test_solvency_protection() { + let env = Env::default(); + + // Create project donations that would exceed matching pool + let project_donations = WeightedProjectDonations { + project_id: 1, + base_total: 5000000u128, // Base donations: 5M + weighted_total: 10000000u128, // Weighted donations: 10M (very high) + unique_donors: 10, + weighted_donations: Vec::new(&env), + matching_amount: 0, + final_payout: 0, + total_boost_applied: 2000000u128, // 2x boost + }; + + let project_donations = Map::from_array(&env, [(1u64, project_donations)]); + + let matching_pool = 1000000u128; // Only 1M matching pool + let quadratic_coefficient = 1000000u128; + + // Calculate weighted matching with solvency protection + let results = StakingMultiplierContract::calculate_weighted_matching( + &env, + &project_donations, + matching_pool, + quadratic_coefficient, + ); + + assert!(results.is_ok()); + + let matching_results = results.unwrap(); + assert_eq!(matching_results.len(), 1); + + // Should only allocate what's available in the pool + let allocated_matching = matching_results.get(1u64).unwrap(); + assert!(allocated_matching <= matching_pool); +} + +#[test] +fn test_integer_square_root() { + let test_values = vec![ + (0u128, 0u128), + (1u128, 1u128), + (4u128, 2u128), + (9u128, 3u128), + (16u128, 4u128), + (25u128, 5u128), + (100u128, 10u128), + (10000u128, 100u128), + (1000000u128, 1000u128), + (100000000u128, 10000u128), + ]; + + for (input, expected) in test_values { + let result = StakingMultiplierContract::integer_square_root(input).unwrap(); + assert_eq!(result, expected, "Failed for input: {}", input); + } +} + +#[test] +fn test_get_donor_staking_weight() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor = Address::generate(&env); + let project_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // Query staking weight + let weight = client.get_donor_staking_weight(&donor, &project_token); + assert!(weight.is_ok()); + + // Weight should be at least base weight + let weight_value = weight.unwrap(); + assert!(weight_value >= BASE_MATCHING_WEIGHT); +} + +#[test] +fn test_get_cached_staking_info() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor = Address::generate(&env); + let project_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // Initially no cache + let cached_info = client.get_cached_staking_info(&donor, &project_token); + assert!(cached_info.is_ok()); + assert!(cached_info.unwrap().is_none()); + + // Query to populate cache + client.query_vesting_stake(&donor, &project_token).unwrap(); + + // Now cache should exist + let cached_info = client.get_cached_staking_info(&donor, &project_token); + assert!(cached_info.is_ok()); + assert!(cached_info.unwrap().is_some()); + + let info = cached_info.unwrap().unwrap(); + assert_eq!(info.donor, donor); + assert_eq!(info.project_token, project_token); + assert!(info.weight_multiplier >= BASE_MATCHING_WEIGHT); +} + +#[test] +fn test_clear_expired_cache() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor = Address::generate(&env); + let project_token = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // Query to populate cache + client.query_vesting_stake(&donor, &project_token).unwrap(); + + // Verify cache exists + let cached_info = client.get_cached_staking_info(&donor, &project_token); + assert!(cached_info.unwrap().is_some()); + + // Clear cache + let cleared_count = client.clear_expired_cache(); + assert!(cleared_count.is_ok()); + assert_eq!(cleared_count.unwrap(), 1); + + // Cache should be expired now + let cached_info = client.get_cached_staking_info(&donor, &project_token); + assert!(cached_info.unwrap().is_none()); +} + +#[test] +fn test_edge_cases() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + + // Test with empty donations + let empty_donations = Vec::new(&env); + let project_token = Address::generate(&env); + + let weighted_donations = client.apply_staking_weights(empty_donations, project_token); + assert!(weighted_donations.is_ok()); + assert_eq!(weighted_donations.unwrap().len(), 0); + + // Test with empty project donations + let empty_project_donations = Map::new(&env); + let matching_pool = 1000000u128; + let quadratic_coefficient = 1000000u128; + + let results = client.calculate_weighted_matching(&env, &empty_project_donations, matching_pool, quadratic_coefficient); + assert!(results.is_ok()); + assert_eq!(results.unwrap().len(), 0); + + // Test integer square root edge cases + assert_eq!(StakingMultiplierContract::integer_square_root(0).unwrap(), 0); + assert_eq!(StakingMultiplierContract::integer_square_root(1).unwrap(), 1); + assert_eq!(StakingMultiplierContract::integer_square_root(u128::MAX).unwrap(), u128::MAX); +} + +#[test] +fn test_boost_multiplier_limits() { + // Test that boost multiplier never exceeds maximum + let extreme_stake = u128::MAX; + let multiplier = StakingMultiplierContract::calculate_boost_multiplier(extreme_stake); + assert_eq!(multiplier, MAX_BOOST_MULTIPLIER); + + // Test minimum stake threshold + let below_minimum = MIN_STAKE_FOR_BOOST - 1; + let multiplier = StakingMultiplierContract::calculate_boost_multiplier(below_minimum); + assert_eq!(multiplier, BASE_MATCHING_WEIGHT); + + // Test exactly at minimum + let at_minimum = MIN_STAKE_FOR_BOOST; + let multiplier = StakingMultiplierContract::calculate_boost_multiplier(at_minimum); + assert_eq!(multiplier, 1200000u128); // 1.2x boost +} + +#[test] +fn test_multiple_donors_same_project() { + let env = Env::default(); + let admin = Address::generate(&env); + let vesting_vault = Address::generate(&env); + let donor1 = Address::generate(&env); + let donor2 = Address::generate(&env); + let donor3 = Address::generate(&env); + let project_token = Address::generate(&env); + let usdc = Address::generate(&env); + + let contract_id = env.register_contract(None, StakingMultiplierContract); + let client = StakingMultiplierClient::new(&env, &contract_id); + + client.initialize(&admin, &vesting_vault); + client.add_project_token(&admin, &project_token); + + // Create donations from multiple donors + let donation1 = crate::multi_token_matching::Donation { + donor: donor1.clone(), + token_address: usdc, + amount: 10000u128, + normalized_value: 10000000000000u128, + timestamp: env.ledger().timestamp(), + round_id: 1, + project_id: 123, + }; + + let donation2 = crate::multi_token_matching::Donation { + donor: donor2.clone(), + token_address: usdc, + amount: 5000u128, + normalized_value: 5000000000000u128, + timestamp: env.ledger().timestamp(), + round_id: 1, + project_id: 123, + }; + + let donation3 = crate::multi_token_matching::Donation { + donor: donor3.clone(), + token_address: usdc, + amount: 2000u128, + normalized_value: 2000000000000u128, + timestamp: env.ledger().timestamp(), + round_id: 1, + project_id: 123, + }; + + let donations = Vec::from_array(&env, [donation1, donation2, donation3]); + + // Apply staking weights + let weighted_donations = client.apply_staking_weights(donations, project_token.clone()); + assert!(weighted_donations.is_ok()); + + let weighted = weighted_donations.unwrap(); + assert_eq!(weighted.len(), 3); + + // Verify each donation has appropriate weight + for weighted_donation in weighted.iter() { + assert!(weighted_donation.weighted_amount >= weighted_donation.base_donation.normalized_value); + assert!(weighted_donation.boost_multiplier >= BASE_MATCHING_WEIGHT); + assert!(weighted_donation.boost_multiplier <= MAX_BOOST_MULTIPLIER); + } + + // Calculate weighted matching for the project + let mut project_donations = Map::new(&env); + + let project_weighted = WeightedProjectDonations { + project_id: 123, + base_total: 17000000000000u128, // 17,000 total + weighted_total: weighted.iter().map(|d| d.weighted_amount).sum(), + unique_donors: 3, + weighted_donations: weighted.clone(), + matching_amount: 0, + final_payout: 0, + total_boost_applied: weighted.iter().map(|d| d.boost_multiplier).sum(), + }; + + project_donations.set(123u64, project_weighted); + + let matching_pool = 500000u128; + let quadratic_coefficient = 1000000u128; + + let results = client.calculate_weighted_matching(&env, &project_donations, matching_pool, quadratic_coefficient); + assert!(results.is_ok()); + + let matching_results = results.unwrap(); + assert_eq!(matching_results.len(), 1); + + let project_matching = matching_results.get(123u64).unwrap(); + assert!(project_matching <= matching_pool); +}