diff --git a/packages/circle/src/contract.rs b/packages/circle/src/contract.rs index 58cf191..594e14c 100644 --- a/packages/circle/src/contract.rs +++ b/packages/circle/src/contract.rs @@ -1,14 +1,14 @@ use soroban_sdk::{Address,BytesN,Env,Vec,Map,symbol_short};use crate::types::*;use crate::payout;use common::{math,pause};use common::reentrancy::ReentrancyGuard;use reputation_registry::scoring; pub fn init(env:&Env,admin:&Address,factory:&Address,config:&CircleConfig)->Result<(),CircleError>{if config.max_members<2||config.contribution_amount<=0||config.total_rounds==0||config.payout_type>3{return Err(CircleError::InvalidAmount);}if config.max_members>scoring::max_circle_size(env,&config.organizer){return Err(CircleError::CircleSizeExceedsTier);}if config.contribution_amount>scoring::max_contribution(env,&config.organizer){return Err(CircleError::ContributionExceedsTier);}let circle=Circle{id:env.current_contract_address(),token:config.token.clone(),name:config.name.clone(),organizer:config.organizer.clone(),factory:factory.clone(),contribution_amount:config.contribution_amount,max_members:config.max_members,member_count:0,payout_type:config.payout_type,total_rounds:config.total_rounds,current_round:0,status:STATUS_PENDING,started_at:0,created_at:env.ledger().timestamp(),contribution_deadline_seconds:config.contribution_deadline_seconds,min_moi_score:config.min_moi_score,collateral_amount:config.collateral_amount,penalty_bps:config.penalty_bps,grace_period_seconds:config.grace_period_seconds,max_strikes:config.max_strikes,payout_bitmap:0,total_payouts:0,total_fees:0,slug:config.slug.clone()};env.storage().instance().set(&DataKey::Circle,&circle);env.storage().instance().set(&DataKey::Admin,admin);env.storage().instance().set(&DataKey::Factory,factory);env.storage().persistent().set(&DataKey::Members,&Vec::::new(env));env.storage().persistent().set(&DataKey::Contributions,&Vec::::new(env));env.storage().persistent().set(&DataKey::Payouts,&Vec::::new(env));env.storage().persistent().set(&DataKey::Bids,&Vec::::new(env));env.storage().persistent().set(&DataKey::Votes,&Vec::::new(env));Ok(())} -pub fn join(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_DISPUTED||circle.status==STATUS_COMPLETED{return Err(CircleError::NotActive);}let allowlist=get_allowlist(env);if allowlist.len()>0{let mut permitted=false;for i in 0..allowlist.len(){if allowlist.get(i).ok_or(CircleError::VecAccessError)?==*member{permitted=true;break;}}if !permitted{return Err(CircleError::AllowlistNotPermitted);}}let score = scoring::get_score(env, member);if score < circle.min_moi_score {return Err(CircleError::InsufficientMoiScore);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).unwrap_or_else(||Vec::new(env));for i in 0..members.len(){if members.get(i).ok_or(CircleError::VecAccessError)?.address==*member{return Err(CircleError::AlreadyMember);}}if members.len()as u32>=circle.max_members{return Err(CircleError::CircleFull);}if circle.collateral_amount>0{let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(member,&circle.id,&circle.collateral_amount);}let now=env.ledger().timestamp();let pos=members.len()as u32;members.push_back(Member{address:member.clone(),position:pos,joined_at:now,strikes:0,status:MEMBER_ACTIVE,exited_at:0,total_contributions:0,total_received:0});circle.member_count=circle.member_count.wrapping_add(1);if circle.member_count>=circle.max_members&&circle.status==STATUS_PENDING{circle.status=STATUS_ACTIVE;circle.started_at=now;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("joined")),MemberJoined{member:member.clone(),position:pos});Ok(())} +pub fn join(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_DISPUTED||circle.status==STATUS_COMPLETED||circle.status==STATUS_CANCELLED{return Err(CircleError::NotActive);}let allowlist=get_allowlist(env);if allowlist.len()>0{let mut permitted=false;for i in 0..allowlist.len(){if allowlist.get(i).ok_or(CircleError::VecAccessError)?==*member{permitted=true;break;}}if !permitted{return Err(CircleError::AllowlistNotPermitted);}}let score = scoring::get_score(env, member);if score < circle.min_moi_score {return Err(CircleError::InsufficientMoiScore);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).unwrap_or_else(||Vec::new(env));for i in 0..members.len(){if members.get(i).ok_or(CircleError::VecAccessError)?.address==*member{return Err(CircleError::AlreadyMember);}}if members.len()as u32>=circle.max_members{return Err(CircleError::CircleFull);}if circle.collateral_amount>0{let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(member,&circle.id,&circle.collateral_amount);}let now=env.ledger().timestamp();let pos=members.len()as u32;members.push_back(Member{address:member.clone(),position:pos,joined_at:now,strikes:0,status:MEMBER_ACTIVE,exited_at:0,total_contributions:0,total_received:0});circle.member_count=circle.member_count.wrapping_add(1);if circle.member_count>=circle.max_members&&circle.status==STATUS_PENDING{circle.status=STATUS_ACTIVE;circle.started_at=now;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("joined")),MemberJoined{member:member.clone(),position:pos});Ok(())} pub fn contribute(env:&Env,member:&Address,amount:i128,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}if amount<=0{return Err(CircleError::InvalidAmount);}if amount>circle.contribution_amount{return Err(CircleError::ContributionMismatch);}if amount>scoring::max_contribution(env,member){return Err(CircleError::ContributionExceedsTier);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut member_index:Option=None;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*member{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}member_index=Some(i);break;}}if member_index.is_none(){return Err(CircleError::NotMember);}let mut contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut contributed_for_round:i128=0;for i in 0..contributions.len(){let c=contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.member==*member&&c.round==round{contributed_for_round=math::safe_add(contributed_for_round,c.amount).map_err(|_|CircleError::InvalidAmount)?;}}if contributed_for_round>=circle.contribution_amount{return Err(CircleError::AlreadyContributed);}let remaining_required=math::safe_sub(circle.contribution_amount,contributed_for_round).map_err(|_|CircleError::InvalidAmount)?;if amount!=remaining_required{return Err(CircleError::ContributionMismatch);}let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(member,&circle.id,&amount);let now=env.ledger().timestamp();let on_time=now<=circle.started_at.wrapping_add(circle.contribution_deadline_seconds);contributions.push_back(Contribution{member:member.clone(),round,amount,timestamp:now,on_time,time_weight:now});env.storage().persistent().set(&DataKey::Contributions,&contributions);if let Some(i)=member_index{let mut stored_member=members.get(i).ok_or(CircleError::VecAccessError)?;stored_member.total_contributions=math::safe_add(stored_member.total_contributions,amount).map_err(|_|CircleError::InvalidAmount)?;members.set(i,stored_member);env.storage().persistent().set(&DataKey::Members,&members);}env.events().publish((env.current_contract_address(),symbol_short!("contrib")),ContributionRecorded{member:member.clone(),round,amount,on_time});scoring::record_on_time_payment(env,member,&circle.id,amount);Ok(())} pub fn trigger_payout(env:&Env,caller:&Address,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;let stored_admin:Address=env.storage().instance().get(&DataKey::Admin).ok_or(CircleError::NotInitialized)?;if caller!=&circle.organizer&&caller!=&stored_admin{return Err(CircleError::Unauthorized);}if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let members_check:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let contribs_check:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));for i in 0..members_check.len(){let m=members_check.get(i).ok_or(CircleError::VecAccessError)?;if m.status==MEMBER_ACTIVE{let mut contributed=false;for j in 0..contribs_check.len(){let c=contribs_check.get(j).ok_or(CircleError::VecAccessError)?;if c.member==m.address&&c.round==round{contributed=true;break;}}if!contributed{return Err(CircleError::ContributionMismatch);}}}let(recipient,payout_type)=match circle.payout_type{PAYOUT_RANDOM=>(payout::resolve_random(env,&circle,round)?,PAYOUT_RANDOM),PAYOUT_FIXED=>(payout::resolve_fixed(env,&circle,round)?,PAYOUT_FIXED),PAYOUT_AUCTION=>{let(w,_)=payout::resolve_auction(env,&circle,round)?;(w,PAYOUT_AUCTION)}PAYOUT_VOTE=>(payout::resolve_vote(env,&circle,round)?,PAYOUT_VOTE),_=>return Err(CircleError::InvalidPayoutType)};let pool=math::safe_mul(circle.contribution_amount,circle.member_count as i128).map_err(|_|CircleError::InvalidAmount)?;let fee_bps:u32=env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0u32);let(net,fee)=math::apply_fee(pool,fee_bps as i128).map_err(|_|CircleError::InvalidAmount)?;let token_client=soroban_sdk::token::Client::new(env,&circle.token);let now=env.ledger().timestamp();let all_contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut total_weighted:u128=0;let mut member_weighted:Map=Map::new(env);for i in 0..all_contributions.len(){let c=all_contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.round==round{let time_held=(now as u128).saturating_sub(c.time_weight as u128);let w=(c.amount as u128).saturating_mul(time_held);total_weighted=total_weighted.saturating_add(w);let prev=member_weighted.get(c.member.clone()).unwrap_or(0);member_weighted.set(c.member.clone(),prev.saturating_add(w));}}let mut payouts:Vec=env.storage().persistent().get(&DataKey::Payouts).unwrap_or_else(||Vec::new(env));let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut distributed:i128=0;let net_u=net as u128;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if let Some(w)=member_weighted.get(m.address.clone()){if total_weighted>0{let share=if distributed==0&&w==total_weighted{net}else{(net_u.saturating_mul(w)/total_weighted)as i128};if share>0{token_client.transfer(&circle.id,&m.address,&share);distributed=math::safe_add(distributed,share).map_err(|_|CircleError::InvalidAmount)?;payouts.push_back(PayoutRecipient{recipient:m.address.clone(),round,amount:share,fee:0,payout_type,timestamp:now});let mut found=false;for j in 0..members.len(){let mut m2=members.get(j).ok_or(CircleError::VecAccessError)?;if m2.address==m.address{m2.total_received=math::safe_add(m2.total_received,share).map_err(|_|CircleError::InvalidAmount)?;members.set(j,m2);found=true;}}}}}}if fee>0&&distributed>0{if let Some(treasury)=env.storage().instance().get::(&DataKey::Treasury){token_client.transfer(&circle.id,&treasury,&fee);}}if distributed=circle.total_rounds{circle.status=STATUS_COMPLETED;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Payouts,&payouts);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("payout")),PayoutExecuted{recipient,round,amount:distributed,fee,payout_type});if circle.status==STATUS_COMPLETED{env.events().publish((env.current_contract_address(),symbol_short!("complete")),CircleCompleted{total_payouts:circle.total_payouts});if circle.collateral_amount>0{for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{token_client.transfer(&circle.id,&m.address,&circle.collateral_amount);}}}let final_members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;for i in 0..final_members.len(){let m=final_members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{scoring::record_circle_completion(env,&m.address);}}}Ok(())} pub fn auction_bid(env:&Env,bidder:&Address,discount_bips:u32,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;bidder.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.payout_type!=PAYOUT_AUCTION{return Err(CircleError::InvalidPayoutType);}if discount_bips>10000{return Err(CircleError::InvalidBid);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let mut bids:Vec=env.storage().persistent().get(&DataKey::Bids).unwrap_or_else(||Vec::new(env));for i in 0..bids.len(){let b=bids.get(i).ok_or(CircleError::VecAccessError)?;if b.bidder==*bidder&&b.round==round{return Err(CircleError::AlreadyBidded);}}bids.push_back(AuctionBid{bidder:bidder.clone(),discount_bips,round,timestamp:env.ledger().timestamp()});env.storage().persistent().set(&DataKey::Bids,&bids);env.events().publish((env.current_contract_address(),symbol_short!("bid")),AuctionBidPlaced{bidder:bidder.clone(),discount_bips,round});Ok(())} pub fn vote_payout(env:&Env,voter:&Address,vote_for:&Address,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;voter.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.payout_type!=PAYOUT_VOTE{return Err(CircleError::InvalidPayoutType);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut is_member=false;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*voter{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}is_member=true;}}if!is_member{return Err(CircleError::NotMember);}let mut votes:Vec=env.storage().persistent().get(&DataKey::Votes).unwrap_or_else(||Vec::new(env));for i in 0..votes.len(){let v=votes.get(i).ok_or(CircleError::VecAccessError)?;if v.voter==*voter&&v.round==round{return Err(CircleError::AlreadyVoted);}}votes.push_back(VoteEntry{voter:voter.clone(),vote_for:vote_for.clone(),round,timestamp:env.ledger().timestamp()});env.storage().persistent().set(&DataKey::Votes,&votes);env.events().publish((env.current_contract_address(),symbol_short!("vote")),VoteCast{voter:voter.clone(),vote_for:vote_for.clone(),round});Ok(())} -pub fn exit(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_COMPLETED{return Err(CircleError::NotActive);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut penalty:i128=0;let mut net:i128=0;let mut found=false;for i in 0..members.len(){let mut m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*member{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}let contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut ctotal:i128=0;for j in 0..contributions.len(){let c=contributions.get(j).ok_or(CircleError::VecAccessError)?;if c.member==*member{ctotal=math::safe_add(ctotal,c.amount).map_err(|_|CircleError::InvalidAmount)?;}}penalty=math::calculate_penalty(ctotal,circle.penalty_bps as i128).map_err(|_|CircleError::InvalidAmount)?;net=math::safe_sub(ctotal,penalty).map_err(|_|CircleError::InvalidAmount)?;m.status=MEMBER_EXITED;m.exited_at=env.ledger().timestamp();members.set(i,m);found=true;}}if !found{return Err(CircleError::NotMember);}env.storage().persistent().set(&DataKey::Members,&members);let token_client=soroban_sdk::token::Client::new(env,&circle.token);if net>0{token_client.transfer(&circle.id,member,&net);}if penalty>0{circle.total_fees=math::safe_add(circle.total_fees,penalty).map_err(|_|CircleError::InvalidAmount)?;env.storage().instance().set(&DataKey::Circle,&circle);if let Some(treasury)=env.storage().instance().get::(&DataKey::Treasury){token_client.transfer(&circle.id,&treasury,&penalty);}}if circle.collateral_amount>0{token_client.transfer(&circle.id,member,&circle.collateral_amount);}env.events().publish((env.current_contract_address(),symbol_short!("exited")),MemberExited{member:member.clone(),penalty});Ok(())} +pub fn exit(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status!=STATUS_ACTIVE&&circle.status!=STATUS_PENDING{return Err(CircleError::NotActive);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut penalty:i128=0;for i in 0..members.len(){let mut m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*member{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}let contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut ctotal:i128=0;for j in 0..contributions.len(){let c=contributions.get(j).ok_or(CircleError::VecAccessError)?;if c.member==*member{ctotal=math::safe_add(ctotal,c.amount).map_err(|_|CircleError::InvalidAmount)?;}}penalty=math::calculate_penalty(ctotal,circle.penalty_bps as i128).map_err(|_|CircleError::InvalidAmount)?;m.status=MEMBER_EXITED;m.exited_at=env.ledger().timestamp();members.set(i,m);}}env.storage().persistent().set(&DataKey::Members,&members);if circle.collateral_amount>0{let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(&circle.id,member,&circle.collateral_amount);}env.events().publish((env.current_contract_address(),symbol_short!("exited")),MemberExited{member:member.clone(),penalty});Ok(())} pub fn report_late(env:&Env,reporter:&Address,late_member:&Address,round:u32)->Result<(),CircleError>{reporter.require_auth();pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}let members_check:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut reporter_active=false;for i in 0..members_check.len(){let m=members_check.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*reporter{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}reporter_active=true;break;}}if !reporter_active{return Err(CircleError::NotMember);}let contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut found=false;for i in 0..contributions.len(){let c=contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.member==*late_member&&c.round==round&&!c.on_time{found=true;}}if!found{return Err(CircleError::NotMember);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;for i in 0..members.len(){let mut m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*late_member{m.strikes=m.strikes.wrapping_add(1);if m.strikes>=circle.max_strikes{m.status=MEMBER_DEFAULTED;scoring::record_default(env,&m.address);env.events().publish((env.current_contract_address(),symbol_short!("default")),MemberDefaulted{member:late_member.clone(),strikes:m.strikes});}members.set(i,m);}}env.storage().persistent().set(&DataKey::Members,&members);Ok(())} pub fn raise_dispute(env:&Env,member:&Address,evidence_hash:&BytesN<32>)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_DISPUTED{return Err(CircleError::DisputeAlreadyRaised);}if env.storage().persistent().get::(&DataKey::Dispute).is_some(){return Err(CircleError::DisputeAlreadyRaised);}circle.status=STATUS_DISPUTED;env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Dispute,&DisputeEntry{raised_by:member.clone(),evidence_hash:evidence_hash.clone(),raised_at:env.ledger().timestamp(),resolved_at:0,resolution:0,resolved_by:env.current_contract_address()});env.events().publish((env.current_contract_address(),symbol_short!("disputed")),DisputeRaised{member:member.clone(),evidence_hash:evidence_hash.clone()});Ok(())} -pub fn resolve_dispute(env:&Env,admin:&Address,resolution:u32)->Result<(),CircleError>{admin.require_auth();let s:Address=env.storage().instance().get(&DataKey::Admin).ok_or(CircleError::NotInitialized)?;if admin!=&s{return Err(CircleError::Unauthorized);}let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;let mut dispute:DisputeEntry=env.storage().persistent().get(&DataKey::Dispute).ok_or(CircleError::NoActiveDispute)?;if resolution>RESOLVE_FORCE_PAYOUT{return Err(CircleError::InvalidAmount);}match resolution{RESOLVE_DISMISS|RESOLVE_PENALIZE|RESOLVE_FORCE_PAYOUT=>circle.status=STATUS_ACTIVE,_=>return Err(CircleError::InvalidAmount)}dispute.resolved_at=env.ledger().timestamp();dispute.resolution=resolution;dispute.resolved_by=admin.clone();env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Dispute,&dispute);Ok(())} +pub fn resolve_dispute(env:&Env,admin:&Address,resolution:u32)->Result<(),CircleError>{admin.require_auth();let s:Address=env.storage().instance().get(&DataKey::Admin).ok_or(CircleError::NotInitialized)?;if admin!=&s{return Err(CircleError::Unauthorized);}let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;let mut dispute:DisputeEntry=env.storage().persistent().get(&DataKey::Dispute).ok_or(CircleError::NoActiveDispute)?;if resolution>RESOLVE_CANCEL{return Err(CircleError::InvalidAmount);}match resolution{RESOLVE_DISMISS|RESOLVE_PENALIZE|RESOLVE_FORCE_PAYOUT=>circle.status=STATUS_ACTIVE,RESOLVE_CANCEL=>{circle.status=STATUS_CANCELLED;let members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let token_client=soroban_sdk::token::Client::new(env,&circle.token);for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;let net_contribution=m.total_contributions.saturating_sub(m.total_received);if net_contribution>0{token_client.transfer(&circle.id,&m.address,&net_contribution);}if circle.collateral_amount>0&&m.status==MEMBER_ACTIVE{token_client.transfer(&circle.id,&m.address,&circle.collateral_amount);}}env.events().publish((env.current_contract_address(),symbol_short!("cancel")),CircleCancelled{circle_id:circle.id.clone(),cancelled_by:admin.clone(),cancelled_at:env.ledger().timestamp()});},_=>return Err(CircleError::InvalidAmount)}dispute.resolved_at=env.ledger().timestamp();dispute.resolution=resolution;dispute.resolved_by=admin.clone();env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Dispute,&dispute);Ok(())} pub fn get_status(env:&Env)->Circle{env.storage().instance().get(&DataKey::Circle).unwrap_or(Circle{id:env.current_contract_address(),token:env.current_contract_address(),name:soroban_sdk::String::from_str(env,""),organizer:env.current_contract_address(),factory:env.current_contract_address(),contribution_amount:0,max_members:0,member_count:0,payout_type:0,total_rounds:0,current_round:0,status:3,started_at:0,created_at:0,contribution_deadline_seconds:0,min_moi_score:0,collateral_amount:0,penalty_bps:0,grace_period_seconds:0,max_strikes:0,payout_bitmap:0,total_payouts:0,total_fees:0,slug:soroban_sdk::String::from_str(env,"")})} pub fn get_members(env:&Env)->Vec{env.storage().persistent().get(&DataKey::Members).unwrap_or_else(||Vec::new(env))} pub fn get_contributions(env:&Env,member:&Address)->Vec{let all:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut out=Vec::new(env);for i in 0..all.len(){if let Some(c)=all.get(i){if c.member==*member{out.push_back(c);}}}out} diff --git a/packages/circle/src/test.rs b/packages/circle/src/test.rs index fb0c556..886e150 100644 --- a/packages/circle/src/test.rs +++ b/packages/circle/src/test.rs @@ -43,7 +43,7 @@ fn setup_circle(env: &Env) -> (CircleClient<'_>, Address, Address) { #[test] fn test_contribute_rejects_amount_above_circle_max() { let env = Env::default(); - let (client, _admin, token) = setup_circle(&env); + let (client, _admin, _token) = setup_circle(&env); let member = Address::generate(&env); let other = Address::generate(&env); @@ -105,3 +105,118 @@ fn test_batch_payout_happy_path() { assert_eq!(token_client.balance(&member_one), 80_i128); assert_eq!(token_client.balance(&member_two), 120_i128); } + +#[test] +fn test_resolve_dispute_cancellation_refunds_contributions() { + let env = Env::default(); + let (client, admin, token) = setup_circle(&env); + let member_one = Address::generate(&env); + let member_two = Address::generate(&env); + + client.join(&member_one); + client.join(&member_two); + + mint_tokens(&env, &token, &member_one, 100); + mint_tokens(&env, &token, &member_two, 100); + client.contribute(&member_one, &100_i128, &0_u32); + client.contribute(&member_two, &100_i128, &0_u32); + + let evidence = soroban_sdk::BytesN::from_array(&env, &[1u8; 32]); + client.raise_dispute(&member_one, &evidence); + + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&member_one), 0_i128); + assert_eq!(token_client.balance(&member_two), 0_i128); + + // Resolve dispute with RESOLVE_CANCEL (4) + client.resolve_dispute(&admin, &crate::types::RESOLVE_CANCEL); + + assert_eq!(client.get_status().status, crate::types::STATUS_CANCELLED); + assert_eq!(token_client.balance(&member_one), 100_i128); + assert_eq!(token_client.balance(&member_two), 100_i128); +} +#[test] +fn test_resolve_dispute_cancellation_refunds_collateral_and_contributions() { + let env = Env::default(); + env.mock_all_auths(); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin); + let mut config = create_config(&env, &token); + config.collateral_amount = 50_i128; + let admin = config.organizer.clone(); + let factory = Address::generate(&env); + let contract_id = env.register(Circle, CircleArgs::__constructor(&admin, &factory, &config)); + let client = CircleClient::new(&env, &contract_id); + + let member_one = Address::generate(&env); + let member_two = Address::generate(&env); + + mint_tokens(&env, &token, &member_one, 150); + mint_tokens(&env, &token, &member_two, 150); + + client.join(&member_one); + client.join(&member_two); + + client.contribute(&member_one, &100_i128, &0_u32); + client.contribute(&member_two, &100_i128, &0_u32); + + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&member_one), 0_i128); + assert_eq!(token_client.balance(&member_two), 0_i128); + + let evidence = soroban_sdk::BytesN::from_array(&env, &[1u8; 32]); + client.raise_dispute(&member_one, &evidence); + + client.resolve_dispute(&admin, &crate::types::RESOLVE_CANCEL); + + assert_eq!(client.get_status().status, crate::types::STATUS_CANCELLED); + assert_eq!(token_client.balance(&member_one), 150_i128); + assert_eq!(token_client.balance(&member_two), 150_i128); +} + +#[test] +fn test_exit_blocked_when_cancelled_or_disputed() { + let env = Env::default(); + let (client, admin, token) = setup_circle(&env); + let member_one = Address::generate(&env); + let member_two = Address::generate(&env); + + client.join(&member_one); + client.join(&member_two); + + mint_tokens(&env, &token, &member_one, 100); + mint_tokens(&env, &token, &member_two, 100); + client.contribute(&member_one, &100_i128, &0_u32); + client.contribute(&member_two, &100_i128, &0_u32); + + let evidence = soroban_sdk::BytesN::from_array(&env, &[1u8; 32]); + client.raise_dispute(&member_one, &evidence); + + // Try exit while disputed + let res_disputed = client.try_exit_circle(&member_one); + assert_eq!(res_disputed, Err(Ok(CircleError::NotActive))); + + client.resolve_dispute(&admin, &crate::types::RESOLVE_CANCEL); + + // Try exit after cancelled + let res_cancelled = client.try_exit_circle(&member_one); + assert_eq!(res_cancelled, Err(Ok(CircleError::NotActive))); +} + +#[test] +fn test_resolve_dispute_unauthorized() { + let env = Env::default(); + let (client, _admin, _token) = setup_circle(&env); + let member_one = Address::generate(&env); + let member_two = Address::generate(&env); + + client.join(&member_one); + client.join(&member_two); + + let evidence = soroban_sdk::BytesN::from_array(&env, &[1u8; 32]); + client.raise_dispute(&member_one, &evidence); + + let non_admin = Address::generate(&env); + let result = client.try_resolve_dispute(&non_admin, &crate::types::RESOLVE_CANCEL); + assert_eq!(result, Err(Ok(CircleError::Unauthorized))); +} diff --git a/packages/circle/src/types.rs b/packages/circle/src/types.rs index cbf6b3d..1efd26e 100644 --- a/packages/circle/src/types.rs +++ b/packages/circle/src/types.rs @@ -1,8 +1,8 @@ use soroban_sdk::{contracttype,contracterror,contractevent,Address,String,BytesN}; pub const PAYOUT_RANDOM:u32=0;pub const PAYOUT_FIXED:u32=1;pub const PAYOUT_AUCTION:u32=2;pub const PAYOUT_VOTE:u32=3; -pub const STATUS_PENDING:u32=0;pub const STATUS_ACTIVE:u32=1;pub const STATUS_COMPLETED:u32=2;pub const STATUS_DISPUTED:u32=4; +pub const STATUS_PENDING:u32=0;pub const STATUS_ACTIVE:u32=1;pub const STATUS_COMPLETED:u32=2;pub const STATUS_CANCELLED:u32=3;pub const STATUS_DISPUTED:u32=4; pub const MEMBER_ACTIVE:u32=0;pub const MEMBER_EXITED:u32=1;pub const MEMBER_DEFAULTED:u32=2; -pub const RESOLVE_DISMISS:u32=1;pub const RESOLVE_PENALIZE:u32=2;pub const RESOLVE_FORCE_PAYOUT:u32=3; +pub const RESOLVE_DISMISS:u32=1;pub const RESOLVE_PENALIZE:u32=2;pub const RESOLVE_FORCE_PAYOUT:u32=3;pub const RESOLVE_CANCEL:u32=4; #[contracttype]#[derive(Clone,Debug)] pub struct CircleConfig{pub organizer:Address,pub token:Address,pub name:String,pub contribution_amount:i128,pub max_members:u32,pub payout_type:u32,pub total_rounds:u32,pub contribution_deadline_seconds:u64,pub min_moi_score:u32,pub collateral_amount:i128,pub penalty_bps:u32,pub grace_period_seconds:u64,pub max_strikes:u32,pub slug:String} #[contracttype]#[derive(Clone,Debug)]