Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 155 additions & 6 deletions contracts/sorosave/src/contribution.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use soroban_sdk::{Address, Env};
use soroban_sdk::{Address, Env, Vec};

use crate::errors::ContractError;
use crate::storage;
use crate::types::{GroupStatus, RoundInfo};
use crate::types::{GroupStatus, RoundInfo, SavingsGroup};

pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> {
member.require_auth();
Expand Down Expand Up @@ -36,6 +36,9 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
if round_info.contributions.contains_key(member.clone()) {
return Err(ContractError::AlreadyContributed);
}
if round_info.misses.contains_key(member.clone()) {
return Err(ContractError::AlreadyContributed);
}

// Transfer tokens from member to this contract
let token_client = soroban_sdk::token::Client::new(env, &group.token);
Expand All @@ -48,11 +51,9 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
// Record contribution
round_info.contributions.set(member.clone(), true);
round_info.total_contributed += group.contribution_amount;
storage::clear_consecutive_misses(env, group_id, &member);

// Check if all members have contributed
if round_info.contributions.len() == group.members.len() {
round_info.is_complete = true;
}
refresh_round_completion(&group, &mut round_info);

storage::set_round(env, group_id, &round_info);

Expand All @@ -78,3 +79,151 @@ pub fn has_contributed(
storage::get_round(env, group_id, round).ok_or(ContractError::RoundNotActive)?;
Ok(round_info.contributions.contains_key(member))
}

pub fn get_consecutive_misses(
env: &Env,
group_id: u64,
member: Address,
) -> Result<u32, ContractError> {
let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?;
ensure_member(&group, &member)?;
Ok(storage::get_consecutive_misses(env, group_id, &member))
}

pub fn record_missed_contribution(
env: &Env,
admin: Address,
group_id: u64,
member: Address,
) -> Result<(), ContractError> {
admin.require_auth();

let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?;

if admin != group.admin && admin != storage::get_admin(env) {
return Err(ContractError::Unauthorized);
}

if group.status != GroupStatus::Active {
return Err(ContractError::GroupNotActive);
}

ensure_member(&group, &member)?;

let mut round_info = storage::get_round(env, group_id, group.current_round)
.ok_or(ContractError::RoundNotActive)?;

if env.ledger().timestamp() < round_info.deadline {
return Err(ContractError::DeadlineNotReached);
}
Comment on lines +116 to +118

if round_info.contributions.contains_key(member.clone()) {
return Err(ContractError::AlreadyContributed);
}
if round_info.misses.contains_key(member.clone()) {
return Err(ContractError::AlreadyContributed);
}

let misses = storage::get_consecutive_misses(env, group_id, &member) + 1;
storage::set_consecutive_misses(env, group_id, &member, misses);
round_info.misses.set(member.clone(), true);

env.events().publish(
(crate::symbol_short!("miss_rec"),),
(group_id, member.clone(), misses),
);

if misses >= group.max_consecutive_misses {
remove_defaulted_member(env, &mut group, &mut round_info, &member)?;
round_info.misses.remove(member.clone());
storage::clear_consecutive_misses(env, group_id, &member);
storage::remove_member_group(env, &member, group_id);

env.events()
.publish((crate::symbol_short!("mbr_rmvd"),), (group_id, member));
}

refresh_round_completion(&group, &mut round_info);

storage::set_round(env, group_id, &round_info);
storage::set_group(env, &group);

Ok(())
}

fn ensure_member(group: &SavingsGroup, member: &Address) -> Result<(), ContractError> {
for m in group.members.iter() {
if m == *member {
return Ok(());
}
}
Err(ContractError::NotMember)
}

fn refresh_round_completion(group: &SavingsGroup, round_info: &mut RoundInfo) {
if round_info.contributions.len() + round_info.misses.len() >= group.members.len() {
round_info.is_complete = true;
}
}

fn remove_defaulted_member(
env: &Env,
group: &mut SavingsGroup,
round_info: &mut RoundInfo,
member: &Address,
) -> Result<(), ContractError> {
let mut remaining_members = Vec::new(env);
for m in group.members.iter() {
if m != *member {
remaining_members.push_back(m);
}
}

if remaining_members.is_empty() {
group.members = remaining_members;
group.status = GroupStatus::Completed;
round_info.is_complete = true;
return Ok(());
}

let replacement = choose_replacement_for_slot(&group.payout_order, &remaining_members, member);
let mut redistributed_order = Vec::new(env);
for recipient in group.payout_order.iter() {
if recipient == *member {
redistributed_order.push_back(replacement.clone());
} else {
redistributed_order.push_back(recipient);
}
}

if round_info.recipient == *member {
round_info.recipient = replacement;
}

group.members = remaining_members;
group.payout_order = redistributed_order;

Ok(())
}

fn choose_replacement_for_slot(
payout_order: &Vec<Address>,
remaining_members: &Vec<Address>,
removed_member: &Address,
) -> Address {
let mut removed_index = 0;
for i in 0..payout_order.len() {
if payout_order.get(i).unwrap() == *removed_member {
removed_index = i;
break;
}
}

let replacement_index = if removed_index < remaining_members.len() {
removed_index
} else {
remaining_members.len() - 1
};

remaining_members.get(replacement_index).unwrap()
}
1 change: 1 addition & 0 deletions contracts/sorosave/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ pub enum ContractError {
InsufficientMembers = 16,
RoundNotComplete = 17,
GroupCompleted = 18,
DeadlineNotReached = 19,
}
28 changes: 28 additions & 0 deletions contracts/sorosave/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub fn create_group(
total_rounds: 0,
status: GroupStatus::Forming,
created_at: env.ledger().timestamp(),
max_consecutive_misses: 3,
};

storage::set_group(env, &group);
Expand Down Expand Up @@ -150,6 +151,7 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr
round_number: 1,
recipient: first_recipient,
contributions: Map::new(env),
misses: Map::new(env),
total_contributed: 0,
is_complete: false,
deadline: env.ledger().timestamp() + group.cycle_length,
Expand All @@ -171,3 +173,29 @@ pub fn get_group(env: &Env, group_id: u64) -> Result<SavingsGroup, ContractError
pub fn get_member_groups(env: &Env, member: Address) -> Vec<u64> {
storage::get_member_groups(env, &member)
}

pub fn set_max_consecutive_misses(
env: &Env,
admin: Address,
group_id: u64,
max_misses: u32,
) -> Result<(), ContractError> {
admin.require_auth();

if max_misses == 0 {
return Err(ContractError::InvalidAmount);
}

let mut group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?;
if admin != group.admin && admin != storage::get_admin(env) {
return Err(ContractError::Unauthorized);
}

group.max_consecutive_misses = max_misses;
storage::set_group(env, &group);

env.events()
.publish((crate::symbol_short!("miss_cfg"),), (group_id, max_misses));

Ok(())
}
29 changes: 29 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ impl SoroSaveContract {
group::start_group(&env, admin, group_id)
}

/// Configure how many consecutive missed contributions remove a member.
pub fn set_max_consecutive_misses(
env: Env,
admin: Address,
group_id: u64,
max_misses: u32,
) -> Result<(), ContractError> {
group::set_max_consecutive_misses(&env, admin, group_id, max_misses)
}

/// Get group details.
pub fn get_group(env: Env, group_id: u64) -> Result<SavingsGroup, ContractError> {
group::get_group(&env, group_id)
Expand Down Expand Up @@ -100,6 +110,25 @@ impl SoroSaveContract {
contribution::has_contributed(&env, member, group_id, round)
}

/// Get a member's current consecutive missed contribution count.
pub fn get_consecutive_misses(
env: Env,
group_id: u64,
member: Address,
) -> Result<u32, ContractError> {
contribution::get_consecutive_misses(&env, group_id, member)
}

/// Record a missed contribution after the active round deadline.
pub fn record_missed_contribution(
env: Env,
admin: Address,
group_id: u64,
member: Address,
) -> Result<(), ContractError> {
contribution::record_missed_contribution(&env, admin, group_id, member)
}

// ─── Payouts ────────────────────────────────────────────────────

/// Distribute the pot to the current round's recipient. Anyone can call this
Expand Down
1 change: 1 addition & 0 deletions contracts/sorosave/src/payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError>
round_number: group.current_round,
recipient: next_recipient,
contributions: Map::new(env),
misses: Map::new(env),
total_contributed: 0,
is_complete: false,
deadline: env.ledger().timestamp() + group.cycle_length,
Expand Down
22 changes: 22 additions & 0 deletions contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ pub fn remove_member_group(env: &Env, member: &Address, group_id: u64) {
extend_persistent_ttl(env, &key);
}

// --- Consecutive missed contributions ---

pub fn get_consecutive_misses(env: &Env, group_id: u64, member: &Address) -> u32 {
let key = DataKey::MemberMisses(group_id, member.clone());
let result = env.storage().persistent().get(&key).unwrap_or(0);
if result > 0 {
extend_persistent_ttl(env, &key);
}
result
Comment on lines +110 to +114
}

pub fn set_consecutive_misses(env: &Env, group_id: u64, member: &Address, misses: u32) {
let key = DataKey::MemberMisses(group_id, member.clone());
env.storage().persistent().set(&key, &misses);
extend_persistent_ttl(env, &key);
}

pub fn clear_consecutive_misses(env: &Env, group_id: u64, member: &Address) {
let key = DataKey::MemberMisses(group_id, member.clone());
env.storage().persistent().remove(&key);
}

// --- Dispute ---

#[allow(dead_code)]
Expand Down
Loading