diff --git a/Cargo.lock b/Cargo.lock index 3f56552..3be2302 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,6 +1256,13 @@ dependencies = [ "wasmparser-nostd", ] +[[package]] +name = "sorususu" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/Cargo.toml b/Cargo.toml index c71fb02..d6803f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["contracts/grant_contracts", "contracts/vesting_contracts"] +members = ["contracts/grant_contracts", "contracts/vesting_contracts", "contracts/sorususu"] [workspace.dependencies] soroban-sdk = "22.0.0" diff --git a/contracts/sorususu/Cargo.toml b/contracts/sorususu/Cargo.toml new file mode 100644 index 0000000..0e570e6 --- /dev/null +++ b/contracts/sorususu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sorususu" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/sorususu/src/lib.rs b/contracts/sorususu/src/lib.rs new file mode 100644 index 0000000..6846346 --- /dev/null +++ b/contracts/sorususu/src/lib.rs @@ -0,0 +1,492 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, symbol_short, + token, Address, Env, Vec, Map, String, Symbol, IntoVal, +}; + +// Constants +const ROUND_DURATION: u64 = 7 * 24 * 60 * 60; // 7 days default round +const MIN_MEMBERS: u32 = 3; +const MAX_MEMBERS: u32 = 50; +const GAS_BOUNTY_BPS: u32 = 10; // 0.1% of platform fee as gas bounty (in basis points) +const PLATFORM_FEE_BPS: u32 = 50; // 0.5% platform fee (in basis points) +const RECOVERY_SURCHARGE_BPS: u32 = 500; // 5% recovery surcharge for deficit (in basis points) +const VOTING_PERIOD: u64 = 3 * 24 * 60 * 60; // 3 days voting period +const DEFICIT_VOTING_THRESHOLD: u32 = 6600; // 66% approval to skip payout (in basis points) +const MIN_RELIABILITY_SCORE: u32 = 900; // Minimum score for grant priority + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Member { + pub address: Address, + pub contribution: i128, + pub reliability_score: u32, + pub rounds_participated: u32, + pub rounds_won: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Round { + pub round_id: u32, + pub members: Vec
, + pub total_pot: i128, + pub winner: Option
, + pub status: RoundStatus, + pub started_at: u64, + pub finalized_at: Option, + pub finalized_by: Option
, + pub gas_bounty_paid: i128, + pub deficit_amount: i128, + pub recovery_surcharge_per_member: i128, + pub deficit_vote_passed: bool, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RoundStatus { + Active, + Finalized, + DeficitPaused, + Skipped, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeficitVote { + pub round_id: u32, + pub votes_for_skip: u32, + pub votes_against_skip: u32, + pub total_voting_power: u32, + pub voting_deadline: u64, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GrantPriorityCache { + pub user: Address, + pub reliability_score: u32, + pub priority_enabled: bool, + pub last_verified: u64, + pub sorususu_contract: Address, +} + +#[contracttype] +pub enum DataKey { + Admin, + PlatformFeeTreasury, + GrantStreamContract, // Address of Grant-Stream contract for interop + Member(Address), + MemberCount, + CurrentRound, + RoundHistory(u32), + DeficitVote(u32), + TotalReliabilityScore(Address), + PlatformFeePool, +} + +#[contracterror] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +#[repr(u32)] +pub enum SusuError { + NotInitialized = 1, + NotAuthorized = 2, + InvalidAmount = 3, + InvalidMember = 4, + RoundNotFound = 5, + RoundAlreadyFinalized = 6, + InsufficientFunds = 7, + MemberLimitReached = 8, + TooFewMembers = 9, + DeficitDetected = 10, + VotingPeriodActive = 11, + VotingPeriodEnded = 12, + ThresholdNotMet = 13, + InvalidReliabilityScore = 14, + GrantStreamContractNotSet = 15, + MathOverflow = 16, +} + +#[contract] +pub struct SoroSusuContract; + +#[contractimpl] +impl SoroSusuContract { + /// Initialize the SoroSusu contract + pub fn initialize( + env: Env, + admin: Address, + platform_fee_treasury: Address, + ) -> Result<(), SusuError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(SusuError::AlreadyInitialized); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::PlatformFeeTreasury, &platform_fee_treasury); + env.storage().instance().set(&DataKey::MemberCount, &0u32); + env.storage().instance().set(&DataKey::CurrentRound, &0u32); + env.storage().instance().set(&DataKey::PlatformFeePool, &0i128); + + env.events().publish( + (symbol_short!("sorususu_init"),), + (admin, platform_fee_treasury), + ); + + Ok(()) + } + + /// Register a new member + pub fn register_member(env: Env, member: Address, initial_contribution: i128) -> Result<(), SusuError> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(SusuError::NotInitialized)?; + admin.require_auth(); + + if initial_contribution <= 0 { + return Err(SusuError::InvalidAmount); + } + + let member_count: u32 = env.storage().instance().get(&DataKey::MemberCount).unwrap_or(0); + if member_count >= MAX_MEMBERS { + return Err(SusuError::MemberLimitReached); + } + + let member_data = Member { + address: member.clone(), + contribution: initial_contribution, + reliability_score: 1000, // Start with perfect score + rounds_participated: 0, + rounds_won: 0, + }; + + env.storage().instance().set(&DataKey::Member(member), &member_data); + env.storage().instance().set(&DataKey::MemberCount, &(member_count + 1)); + + env.events().publish( + (symbol_short!("member_registered"),), + (member, initial_contribution), + ); + + Ok(()) + } + + /// Start a new round + pub fn start_round(env: Env, members: Vec
) -> Result { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(SusuError::NotInitialized)?; + admin.require_auth(); + + if members.len() < MIN_MEMBERS { + return Err(SusuError::TooFewMembers); + } + + let current_round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap_or(0); + let new_round_id = current_round_id + 1; + + // Calculate total pot from member contributions + let mut total_pot = 0i128; + for member_addr in members.iter() { + let member: Member = env.storage().instance() + .get(&DataKey::Member(member_addr)) + .ok_or(SusuError::InvalidMember)?; + total_pot += member.contribution; + } + + let round = Round { + round_id: new_round_id, + members: members.clone(), + total_pot, + winner: None, + status: RoundStatus::Active, + started_at: env.ledger().timestamp(), + finalized_at: None, + finalized_by: None, + gas_bounty_paid: 0, + deficit_amount: 0, + recovery_surcharge_per_member: 0, + deficit_vote_passed: false, + }; + + env.storage().instance().set(&DataKey::CurrentRound, &new_round_id); + env.storage().instance().set(&DataKey::RoundHistory(new_round_id), &round); + + env.events().publish( + (symbol_short!("round_started"),), + (new_round_id, members.len(), total_pot), + ); + + Ok(new_round_id) + } + + /// TASK 1: Finalize round with gas bounty incentive + /// Anyone can call this to move the pot to the winner and receive a gas rebate + pub fn finalize_round(env: Env, round_id: u32, winner: Address) -> Result<(), SusuError> { + // No auth required - this is permissionless for decentralized maintenance + + let mut round: Round = env.storage().instance() + .get(&DataKey::RoundHistory(round_id)) + .ok_or(SusuError::RoundNotFound)?; + + if round.status != RoundStatus::Active { + return Err(SusuError::RoundAlreadyFinalized); + } + + // Check if round duration has passed + let now = env.ledger().timestamp(); + if now < round.started_at + ROUND_DURATION { + return Err(SusuError::VotingPeriodActive); + } + + // Check for clawback deficit (TASK 2 integration) + let token_address: Address = env.storage().instance() + .get(&DataKey::GrantToken) + .ok_or(SusuError::NotInitialized)?; + let token_client = token::Client::new(&env, &token_address); + let contract_balance = token_client.balance(&env.current_contract_address()); + + if contract_balance < round.total_pot { + // Deficit detected - activate deficit resolution (TASK 2) + return Self::activate_deficit_resolution(&env, round_id, contract_balance)?; + } + + // Calculate platform fee + let platform_fee = (round.total_pot * PLATFORM_FEE_BPS as i128) / 10000; + let winner_amount = round.total_pot - platform_fee; + + // Calculate gas bounty for the caller + let caller = env.invoker(); + let gas_bounty = (platform_fee * GAS_BOUNTY_BPS as i128) / 10000; + let net_fee = platform_fee - gas_bounty; + + // Transfer winner amount + token_client.transfer(&env.current_contract_address(), &winner, &winner_amount); + + // Transfer platform fee to treasury + let treasury: Address = env.storage().instance().get(&DataKey::PlatformFeeTreasury).ok_or(SusuError::NotInitialized)?; + token_client.transfer(&env.current_contract_address(), &treasury, &net_fee); + + // Pay gas bounty to caller + if gas_bounty > 0 { + token_client.transfer(&env.current_contract_address(), &caller, &gas_bounty); + } + + // Update round state + round.winner = Some(winner.clone()); + round.status = RoundStatus::Finalized; + round.finalized_at = Some(now); + round.finalized_by = Some(caller.clone()); + round.gas_bounty_paid = gas_bounty; + + env.storage().instance().set(&DataKey::RoundHistory(round_id), &round); + + // Update winner's reliability score + Self::update_member_reliability(&env, &winner, true)?; + + env.events().publish( + (symbol_short!("round_finalized"),), + (round_id, winner, caller, gas_bounty, winner_amount), + ); + + Ok(()) + } + + /// TASK 2: Activate deficit resolution when clawback is detected + fn activate_deficit_resolution(env: &Env, round_id: u32, actual_balance: i128) -> Result<(), SusuError> { + let mut round: Round = env.storage().instance() + .get(&DataKey::RoundHistory(round_id)) + .ok_or(SusuError::RoundNotFound)?; + + let deficit_amount = round.total_pot - actual_balance; + round.status = RoundStatus::DeficitPaused; + round.deficit_amount = deficit_amount; + + // Calculate recovery surcharge per member + let surcharge_per_member = (deficit_amount * RECOVERY_SURCHARGE_BPS as i128) + / (round.members.len() as i128 * 10000); + round.recovery_surcharge_per_member = surcharge_per_member; + + env.storage().instance().set(&DataKey::RoundHistory(round_id), &round); + + // Create deficit vote + let vote = DeficitVote { + round_id, + votes_for_skip: 0, + votes_against_skip: 0, + total_voting_power: round.members.len() as u32, + voting_deadline: env.ledger().timestamp() + VOTING_PERIOD, + executed: false, + }; + + env.storage().instance().set(&DataKey::DeficitVote(round_id), &vote); + + env.events().publish( + (symbol_short!("deficit_detected"),), + (round_id, deficit_amount, surcharge_per_member), + ); + + Err(SusuError::DeficitDetected) + } + + /// TASK 2: Vote on deficit resolution (skip payout or pay surcharge) + pub fn vote_on_deficit(env: Env, round_id: u32, vote_for_skip: bool) -> Result<(), SusuError> { + let voter = env.invoker(); + + let round: Round = env.storage().instance() + .get(&DataKey::RoundHistory(round_id)) + .ok_or(SusuError::RoundNotFound)?; + + // Verify voter is a member + let is_member = round.members.iter().any(|m| m == voter); + if !is_member { + return Err(SusuError::InvalidMember); + } + + let mut vote: DeficitVote = env.storage().instance() + .get(&DataKey::DeficitVote(round_id)) + .ok_or(SusuError::VotingPeriodActive)?; + + // Check voting period + let now = env.ledger().timestamp(); + if now > vote.voting_deadline { + return Err(SusuError::VotingPeriodEnded); + } + + // Record vote + if vote_for_skip { + vote.votes_for_skip += 1; + } else { + vote.votes_against_skip += 1; + } + + env.storage().instance().set(&DataKey::DeficitVote(round_id), &vote); + + env.events().publish( + (symbol_short!("deficit_vote"),), + (round_id, voter, vote_for_skip, vote.votes_for_skip, vote.votes_against_skip), + ); + + Ok(()) + } + + /// TASK 2: Execute deficit vote result + pub fn execute_deficit_vote(env: Env, round_id: u32) -> Result<(), SusuError> { + let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(SusuError::NotInitialized)?; + admin.require_auth(); + + let vote: DeficitVote = env.storage().instance() + .get(&DataKey::DeficitVote(round_id)) + .ok_or(SusuError::VotingPeriodActive)?; + + // Check voting period ended + let now = env.ledger().timestamp(); + if now < vote.voting_deadline { + return Err(SusuError::VotingPeriodActive); + } + + // Check if threshold met + let approval_percentage = (vote.votes_for_skip * 10000) / vote.total_voting_power; + if approval_percentage < DEFICIT_VOTING_THRESHOLD { + return Err(SusuError::ThresholdNotMet); + } + + // Skip payout - mark round as skipped + let mut round: Round = env.storage().instance() + .get(&DataKey::RoundHistory(round_id)) + .ok_or(SusuError::RoundNotFound)?; + + round.status = RoundStatus::Skipped; + round.deficit_vote_passed = true; + + env.storage().instance().set(&DataKey::RoundHistory(round_id), &round); + + env.events().publish( + (symbol_short!("deficit_vote_executed"),), + (round_id, vote.votes_for_skip, vote.total_voting_power), + ); + + Ok(()) + } + + /// TASK 3: Get reliability score for inter-contract queries + pub fn get_reliability_score(env: Env, user: Address) -> Result { + let member: Member = env.storage().instance() + .get(&DataKey::Member(user)) + .ok_or(SusuError::InvalidMember)?; + + Ok(member.reliability_score) + } + + /// TASK 3: Verify if user qualifies for grant priority (score > 900) + pub fn verify_grant_priority_eligible(env: Env, user: Address) -> Result { + let score = Self::get_reliability_score(env, user)?; + Ok(score > MIN_RELIABILITY_SCORE) + } + + /// TASK 3: Set Grant-Stream contract address for interop + pub fn set_grant_stream_contract(env: Env, admin: Address, contract_addr: Address) -> Result<(), SusuError> { + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(SusuError::NotInitialized)?; + if admin != stored_admin { + return Err(SusuError::NotAuthorized); + } + + env.storage().instance().set(&DataKey::GrantStreamContract, &contract_addr); + + env.events().publish( + (symbol_short!("grant_stream_set"),), + (admin, contract_addr), + ); + + Ok(()) + } + + /// Helper: Update member reliability score based on round outcome + fn update_member_reliability(env: &Env, member: &Address, won_round: bool) -> Result<(), SusuError> { + let mut member_data: Member = env.storage().instance() + .get(&DataKey::Member(member)) + .ok_or(SusuError::InvalidMember)?; + + member_data.rounds_participated += 1; + if won_round { + member_data.rounds_won += 1; + } + + // Calculate new reliability score + // Formula: (rounds_won / rounds_participated) * 1000 + // Members who win fairly get high scores, those with deficits get penalized + let base_score = if member_data.rounds_participated > 0 { + (member_data.rounds_won as u32 * 1000) / member_data.rounds_participated + } else { + 1000 + }; + + member_data.reliability_score = base_score.min(1000); + + env.storage().instance().set(&DataKey::Member(member), &member_data); + + Ok(()) + } + + /// Get round details + pub fn get_round(env: Env, round_id: u32) -> Result { + env.storage().instance() + .get(&DataKey::RoundHistory(round_id)) + .ok_or(SusuError::RoundNotFound) + } + + /// Get member details + pub fn get_member(env: Env, member: Address) -> Result { + env.storage().instance() + .get(&DataKey::Member(member)) + .ok_or(SusuError::InvalidMember) + } + + /// Get current round ID + pub fn get_current_round(env: Env) -> Result { + env.storage().instance() + .get(&DataKey::CurrentRound) + .ok_or(SusuError::RoundNotFound) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/sorususu/src/test.rs b/contracts/sorususu/src/test.rs new file mode 100644 index 0000000..4901e19 --- /dev/null +++ b/contracts/sorususu/src/test.rs @@ -0,0 +1,367 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, Vec, +}; + +use crate::{SoroSusuContract, SoroSusuContractClient, Member, Round, RoundStatus, DeficitVote}; + +fn set_timestamp(env: &Env, timestamp: u64) { + env.ledger().with_mut(|li| { + li.timestamp = timestamp; + }); +} + +/// TASK 1: Test gas bounty incentive for finalizing rounds +#[test] +fn test_gas_bounty_finalize_round() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + // Initialize contract + client.initialize(&admin, &treasury); + + // Register members + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + let member3 = Address::generate(&env); + + client.register_member(&member1, &1000); + client.register_member(&member2, &1000); + client.register_member(&member3, &1000); + + // Start round + let mut members = Vec::new(&env); + members.push_back(member1.clone()); + members.push_back(member2.clone()); + members.push_back(member3.clone()); + + let round_id = client.start_round(&members); + + // Advance time beyond round duration (7 days + 1 hour) + set_timestamp(&env, 1000 + 7 * 24 * 60 * 60 + 3600); + + // Anyone can finalize (permissionless) + let random_user = Address::generate(&env); + env.mock_all_auths(); + + // Finalize round - random user should receive gas bounty + client.finalize_round(&round_id, &member1); + + // Verify round was finalized + let round: Round = client.get_round(&round_id); + assert_eq!(round.status, RoundStatus::Finalized); + assert_eq!(round.winner, Some(member1.clone())); + assert!(round.finalized_by.is_some()); + assert!(round.gas_bounty_paid > 0); + + // Verify gas bounty was paid to the caller (random_user) + // The caller should receive 0.1% of platform fee (0.5% of pot) + let expected_pot = 3000; // 3 members * 1000 + let expected_platform_fee = (expected_pot * 50) / 10000; // 0.5% = 15 + let expected_gas_bounty = (expected_platform_fee * 10) / 10000; // 0.1% of fee = 0.015 + + assert!(round.gas_bounty_paid >= expected_gas_bounty); +} + +/// TASK 2: Test clawback deficit detection and resolution +#[test] +fn test_clawback_deficit_resolution() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Register members + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + let member3 = Address::generate(&env); + + client.register_member(&member1, &1000); + client.register_member(&member2, &1000); + client.register_member(&member3, &1000); + + // Start round + let mut members = Vec::new(&env); + members.push_back(member1.clone()); + members.push_back(member2.clone()); + members.push_back(member3.clone()); + + let round_id = client.start_round(&members); + + // Simulate clawback: Remove 500 from contract balance + // In reality this would happen via external regulated asset clawback + // For testing, we'll test the deficit detection logic + + // Advance time + set_timestamp(&env, 1000 + 7 * 24 * 60 * 60 + 3600); + + // Try to finalize - should detect deficit and pause + let result = std::panic::catch_unwind(|| { + client.finalize_round(&round_id, &member1); + }); + + // Should fail with DeficitDetected error + assert!(result.is_err()); + + // Verify round is in DeficitPaused state + let round: Round = client.get_round(&round_id); + assert_eq!(round.status, RoundStatus::DeficitPaused); + assert!(round.deficit_amount > 0); + assert!(round.recovery_surcharge_per_member > 0); +} + +/// TASK 2: Test voting on deficit resolution +#[test] +fn test_deficit_vote_skip_payout() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Register members + let member1 = Address::generate(&env); + let member2 = Address::generate(&env); + let member3 = Address::generate(&env); + + client.register_member(&member1, &1000); + client.register_member(&member2, &1000); + client.register_member(&member3, &1000); + + // Start round + let mut members = Vec::new(&env); + members.push_back(member1.clone()); + members.push_back(member2.clone()); + members.push_back(member3.clone()); + + let round_id = client.start_round(&members); + + // Trigger deficit (manually set round status for testing) + // In real scenario, this happens via finalize_round detecting balance < pot + + // Members vote to skip payout + env.mock_all_auths(); + client.vote_on_deficit(&round_id, &true); // member1 votes for skip + client.vote_on_deficit(&round_id, &true); // member2 votes for skip + client.vote_on_deficit(&round_id, &false); // member3 votes against + + // Advance past voting period (3 days + 1 hour) + set_timestamp(&env, 1000 + 3 * 24 * 60 * 60 + 3600); + + // Execute vote (needs 66% approval) + client.execute_deficit_vote(&round_id); + + // Verify round was skipped + let round: Round = client.get_round(&round_id); + assert_eq!(round.status, RoundStatus::Skipped); + assert!(round.deficit_vote_passed); +} + +/// TASK 3: Test inter-contract reliability score query +#[test] +fn test_reliability_score_grant_priority() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Register member with perfect participation + let good_member = Address::generate(&env); + client.register_member(&good_member, &1000); + + // Simulate multiple successful rounds + for _i in 0..5 { + let mut members = Vec::new(&env); + members.push_back(good_member.clone()); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + + let round_id = client.start_round(&members); + + // Advance time and finalize + set_timestamp(&env, 1000 + 7 * 24 * 60 * 60 + 3600); + client.finalize_round(&round_id, &good_member); + } + + // Check reliability score + let member_data: Member = client.get_member(&good_member); + assert!(member_data.reliability_score > 900); // Should have high score + + // Verify grant priority eligibility + let eligible = client.verify_grant_priority_eligible(&good_member); + assert!(eligible); // Should qualify for priority +} + +/// TASK 3: Test grant stream contract integration +#[test] +fn test_set_grant_stream_contract() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Set Grant-Stream contract address + let grant_stream_contract = Address::generate(&env); + client.set_grant_stream_contract(&admin, &grant_stream_contract); + + // Verify it was set (would be stored in DataKey::GrantStreamContract) + // In production, Grant-Stream contract would call get_reliability_score + let member = Address::generate(&env); + client.register_member(&member, &1000); + + let score = client.get_reliability_score(&member); + assert!(score >= 0); +} + +/// Test recovery surcharge calculation +#[test] +fn test_recovery_surcharge_calculation() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Register 10 members + let mut members = Vec::new(&env); + for i in 0..10 { + let member = Address::generate(&env); + client.register_member(&member, &1000); + members.push_back(member); + } + + let round_id = client.start_round(&members); + + // Simulate 20% deficit (6000 out of 10000 missing) + // Recovery surcharge should be 5% split among members + + // Advance time + set_timestamp(&env, 1000 + 7 * 24 * 60 * 60 + 3600); + + // Trigger deficit + let result = std::panic::catch_unwind(|| { + client.finalize_round(&round_id, &members.get(0).unwrap()); + }); + + assert!(result.is_err()); + + let round: Round = client.get_round(&round_id); + + // Verify surcharge is calculated correctly + // 5% of deficit / 10 members + let expected_surcharge = (round.deficit_amount * 500) / (10 * 10000); + assert_eq!(round.recovery_surcharge_per_member, expected_surcharge); +} + +/// Test that non-members cannot vote on deficit +#[test] +fn test_non_member_cannot_vote_deficit() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Register members + let member1 = Address::generate(&env); + client.register_member(&member1, &1000); + + let mut members = Vec::new(&env); + members.push_back(member1.clone()); + members.push_back(Address::generate(&env)); + members.push_back(Address::generate(&env)); + + let round_id = client.start_round(&members); + + // Non-member tries to vote + let outsider = Address::generate(&env); + env.mock_all_auths(); + + let result = std::panic::catch_unwind(|| { + client.vote_on_deficit(&round_id, &outsider, &true); + }); + + // Should fail + assert!(result.is_err()); +} + +/// Test gas bounty economics +#[test] +fn test_gas_bounty_economics() { + let env = Env::default(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register(SoroSusuContract, ()); + let client = SoroSusuContractClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury); + + // Create large round + let mut members = Vec::new(&env); + for _i in 0..10 { + let member = Address::generate(&env); + client.register_member(&member, &10000); + members.push_back(member); + } + + let round_id = client.start_round(&members); + + // Advance time + set_timestamp(&env, 1000 + 7 * 24 * 60 * 60 + 3600); + + // Multiple users try to finalize (race condition test) + let winner = members.get(0).unwrap(); + let caller1 = Address::generate(&env); + let caller2 = Address::generate(&env); + + env.mock_all_auths(); + + // First caller succeeds + client.finalize_round(&round_id, &winner); + + let round: Round = client.get_round(&round_id); + + // Verify platform fee structure + let total_pot = 100000; // 10 members * 10000 + let platform_fee_bps = 50; // 0.5% + let gas_bounty_bps = 10; // 0.1% of fee + + let expected_fee = (total_pot * platform_fee_bps) / 10000; + let expected_bounty = (expected_fee * gas_bounty_bps) / 10000; + + assert!(round.gas_bounty_paid >= expected_bounty); + + // Second caller should fail (round already finalized) + let result = std::panic::catch_unwind(|| { + client.finalize_round(&round_id, &winner); + }); + assert!(result.is_err()); +}