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
57 changes: 53 additions & 4 deletions contracts/sorosave/src/contribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,54 @@ use soroban_sdk::{Address, Env};

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

pub fn required_contribution_for_member(
env: &Env,
group: &SavingsGroup,
member: &Address,
) -> Result<i128, ContractError> {
let mut is_member = false;
for m in group.members.iter() {
if m == member.clone() {
is_member = true;
break;
}
}
if !is_member {
return Err(ContractError::NotMember);
}

if group.contribution_type == ContributionType::Fixed {
return Ok(group.contribution_amount);
}

let base_amount = storage::get_member_base_amount(env, group.id, member)
.ok_or(ContractError::InvalidAmount)?;
if base_amount <= 0 {
return Err(ContractError::InvalidAmount);
}

let denominator = storage::MAX_PERCENTAGE_BPS as i128;
let bps = group.contribution_percentage_bps as i128;
let required_amount =
(base_amount / denominator) * bps + (base_amount % denominator) * bps / denominator;

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

Ok(required_amount)
}

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

pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), ContractError> {
member.require_auth();
Expand Down Expand Up @@ -37,17 +84,19 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
return Err(ContractError::AlreadyContributed);
}

let contribution_amount = required_contribution_for_member(env, &group, &member)?;

// Transfer tokens from member to this contract
let token_client = soroban_sdk::token::Client::new(env, &group.token);
token_client.transfer(
&member,
&env.current_contract_address(),
&group.contribution_amount,
&contribution_amount,
);

// Record contribution
round_info.contributions.set(member.clone(), true);
round_info.total_contributed += group.contribution_amount;
round_info.total_contributed += contribution_amount;

// Check if all members have contributed
if round_info.contributions.len() == group.members.len() {
Expand All @@ -58,7 +107,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr

env.events().publish(
(crate::symbol_short!("contrib"),),
(group_id, member, group.contribution_amount),
(group_id, member, contribution_amount),
);

Ok(())
Expand Down
148 changes: 144 additions & 4 deletions contracts/sorosave/src/group.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
use soroban_sdk::{Address, Env, Map, String, Vec};

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

struct ContributionConfig {
amount: i128,
contribution_type: ContributionType,
percentage_bps: u32,
}

pub fn create_group(
env: &Env,
Expand All @@ -12,12 +19,72 @@ pub fn create_group(
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
create_group_with_type(
env,
admin,
name,
token,
ContributionConfig {
amount: contribution_amount,
contribution_type: ContributionType::Fixed,
percentage_bps: 0,
},
cycle_length,
max_members,
)
}

pub fn create_percentage_group(
env: &Env,
admin: Address,
name: String,
token: Address,
contribution_percentage_bps: u32,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
create_group_with_type(
env,
admin,
name,
token,
ContributionConfig {
amount: 0,
contribution_type: ContributionType::Percentage,
percentage_bps: contribution_percentage_bps,
},
cycle_length,
max_members,
)
}

fn create_group_with_type(
env: &Env,
admin: Address,
name: String,
token: Address,
contribution_config: ContributionConfig,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
admin.require_auth();

if contribution_amount <= 0 {
return Err(ContractError::InvalidAmount);
match contribution_config.contribution_type {
ContributionType::Fixed => {
if contribution_config.amount <= 0 {
return Err(ContractError::InvalidAmount);
}
}
ContributionType::Percentage => {
if contribution_config.percentage_bps == 0
|| contribution_config.percentage_bps > storage::MAX_PERCENTAGE_BPS
{
return Err(ContractError::InvalidAmount);
}
}
}

if max_members < 2 {
return Err(ContractError::InsufficientMembers);
}
Expand All @@ -33,7 +100,9 @@ pub fn create_group(
name,
admin: admin.clone(),
token,
contribution_amount,
contribution_amount: contribution_config.amount,
contribution_type: contribution_config.contribution_type,
contribution_percentage_bps: contribution_config.percentage_bps,
cycle_length,
max_members,
members,
Expand Down Expand Up @@ -83,6 +152,70 @@ pub fn join_group(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
Ok(())
}

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

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

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

if group.contribution_type != ContributionType::Percentage {
return Err(ContractError::InvalidAmount);
}

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

let mut is_member = false;
for m in group.members.iter() {
if m == member {
is_member = true;
break;
}
}
if !is_member {
return Err(ContractError::NotMember);
}

storage::set_member_base_amount(env, group_id, &member, base_amount);

env.events().publish(
(crate::symbol_short!("base_amt"),),
(group_id, member, base_amount),
);

Ok(())
}

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

let mut is_member = false;
for m in group.members.iter() {
if m == member {
is_member = true;
break;
}
}
if !is_member {
return Err(ContractError::NotMember);
}

Ok(storage::get_member_base_amount(env, group_id, &member).unwrap_or(0))
}

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

Expand Down Expand Up @@ -114,6 +247,7 @@ pub fn leave_group(env: &Env, member: Address, group_id: u64) -> Result<(), Cont
group.members = new_members;
storage::set_group(env, &group);
storage::remove_member_group(env, &member, group_id);
storage::remove_member_base_amount(env, group_id, &member);

env.events()
.publish((crate::symbol_short!("grp_leav"),), (group_id, member));
Expand All @@ -138,6 +272,12 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr
return Err(ContractError::InsufficientMembers);
}

if group.contribution_type == ContributionType::Percentage {
for member in group.members.iter() {
contribution::required_contribution_for_member(env, &group, &member)?;
}
}

// Set payout order to member join order (can be randomized later)
group.payout_order = group.members.clone();
group.total_rounds = group.members.len();
Expand Down
50 changes: 50 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,52 @@ impl SoroSaveContract {
)
}

/// Create a savings group where each member contributes a percentage of
/// their declared base amount.
pub fn create_percentage_group(
env: Env,
admin: Address,
name: String,
token: Address,
contribution_percentage_bps: u32,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
group::create_percentage_group(
&env,
admin,
name,
token,
contribution_percentage_bps,
cycle_length,
max_members,
)
}

/// Join an existing group that is still forming.
pub fn join_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> {
group::join_group(&env, member, group_id)
}

/// Declare a member's base amount for percentage-based contributions.
pub fn set_member_base_amount(
env: Env,
member: Address,
group_id: u64,
base_amount: i128,
) -> Result<(), ContractError> {
group::set_member_base_amount(&env, member, group_id, base_amount)
}

/// Get a member's declared base amount for a group.
pub fn get_member_base_amount(
env: Env,
member: Address,
group_id: u64,
) -> Result<i128, ContractError> {
group::get_member_base_amount(&env, member, group_id)
}

/// Leave a group (only allowed while group is still forming).
pub fn leave_group(env: Env, member: Address, group_id: u64) -> Result<(), ContractError> {
group::leave_group(&env, member, group_id)
Expand Down Expand Up @@ -81,6 +122,15 @@ impl SoroSaveContract {
contribution::contribute(&env, member, group_id)
}

/// Get the amount a member is required to contribute to a group.
pub fn get_required_contribution(
env: Env,
member: Address,
group_id: u64,
) -> Result<i128, ContractError> {
contribution::get_required_contribution(&env, member, group_id)
}

/// Get the status of a specific round.
pub fn get_round_status(
env: Env,
Expand Down
23 changes: 23 additions & 0 deletions contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const INSTANCE_TTL_THRESHOLD: u32 = 100;
const INSTANCE_TTL_EXTEND: u32 = 500;
const PERSISTENT_TTL_THRESHOLD: u32 = 100;
const PERSISTENT_TTL_EXTEND: u32 = 1000;
pub const MAX_PERCENTAGE_BPS: u32 = 10_000;

// --- Admin ---

Expand Down Expand Up @@ -103,6 +104,28 @@ pub fn remove_member_group(env: &Env, member: &Address, group_id: u64) {
extend_persistent_ttl(env, &key);
}

// --- Member Base Amounts ---

pub fn get_member_base_amount(env: &Env, group_id: u64, member: &Address) -> Option<i128> {
let key = DataKey::MemberBaseAmount(group_id, member.clone());
let result = env.storage().persistent().get(&key);
if result.is_some() {
extend_persistent_ttl(env, &key);
}
result
}

pub fn set_member_base_amount(env: &Env, group_id: u64, member: &Address, base_amount: i128) {
let key = DataKey::MemberBaseAmount(group_id, member.clone());
env.storage().persistent().set(&key, &base_amount);
extend_persistent_ttl(env, &key);
}

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

// --- Dispute ---

#[allow(dead_code)]
Expand Down
Loading