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

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

const BASIS_POINTS_DENOMINATOR: i128 = 10_000;

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

let contribution_amount = match group.contribution_type {
ContributionType::Fixed => group.contribution_amount,
ContributionType::Percentage => {
let base_amount = storage::get_member_base_amount(env, group_id, &member)
.ok_or(ContractError::BaseAmountRequired)?;
let amount = (base_amount * group.contribution_amount) / BASIS_POINTS_DENOMINATOR;
if amount <= 0 {
return Err(ContractError::InvalidAmount);
}
amount
}
};

// 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 +73,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
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,
BaseAmountRequired = 19,
}
102 changes: 101 additions & 1 deletion contracts/sorosave/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use soroban_sdk::{Address, Env, Map, String, Vec};

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

const MAX_PERCENTAGE_BPS: i128 = 10_000;

pub fn create_group(
env: &Env,
Expand All @@ -12,12 +14,38 @@ pub fn create_group(
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
create_group_with_type(
env,
admin,
name,
token,
contribution_amount,
ContributionType::Fixed,
cycle_length,
max_members,
)
}

pub fn create_group_with_type(
env: &Env,
admin: Address,
name: String,
token: Address,
contribution_amount: i128,
contribution_type: ContributionType,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
admin.require_auth();

if contribution_amount <= 0 {
return Err(ContractError::InvalidAmount);
}
if contribution_type == ContributionType::Percentage && contribution_amount > MAX_PERCENTAGE_BPS
{
return Err(ContractError::InvalidAmount);
}
if max_members < 2 {
return Err(ContractError::InsufficientMembers);
}
Expand All @@ -33,6 +61,7 @@ pub fn create_group(
name,
admin: admin.clone(),
token,
contribution_type,
contribution_amount,
cycle_length,
max_members,
Expand Down Expand Up @@ -83,6 +112,69 @@ 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.status != GroupStatus::Forming {
return Err(ContractError::GroupNotForming);
}
if group.contribution_type != ContributionType::Percentage {
return Err(ContractError::InvalidAmount);
}

let mut is_member = false;
for existing_member in group.members.iter() {
if existing_member == 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_set"),),
(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 existing_member in group.members.iter() {
if existing_member == member {
is_member = true;
break;
}
}
if !is_member {
return Err(ContractError::NotMember);
}

storage::get_member_base_amount(env, group_id, &member).ok_or(ContractError::BaseAmountRequired)
}

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

Expand Down Expand Up @@ -114,6 +206,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 @@ -137,6 +230,13 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr
if group.members.len() < 2 {
return Err(ContractError::InsufficientMembers);
}
if group.contribution_type == ContributionType::Percentage {
for member in group.members.iter() {
if storage::get_member_base_amount(env, group_id, &member).is_none() {
return Err(ContractError::BaseAmountRequired);
}
}
}

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

/// Create a new savings group with an explicit contribution model.
pub fn create_group_with_type(
env: Env,
admin: Address,
name: String,
token: Address,
contribution_amount: i128,
contribution_type: ContributionType,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
group::create_group_with_type(
&env,
admin,
name,
token,
contribution_amount,
contribution_type,
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)
}

/// Set a member's base amount for percentage-based groups while still forming.
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
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);
}

// --- 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
55 changes: 53 additions & 2 deletions contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String};
use soroban_sdk::{
testutils::Address as _,
token::{Client as TokenClient, StellarAssetClient},
Address, Env, String,
};

use crate::types::GroupStatus;
use crate::types::{ContributionType, GroupStatus};
use crate::{SoroSaveContract, SoroSaveContractClient};

fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) {
Expand Down Expand Up @@ -46,6 +50,7 @@ fn test_create_group() {

let group = client.get_group(&group_id);
assert_eq!(group.admin, admin);
assert_eq!(group.contribution_type, ContributionType::Fixed);
assert_eq!(group.contribution_amount, 1_000_000);
assert_eq!(group.max_members, 5);
assert_eq!(group.status, GroupStatus::Forming);
Expand Down Expand Up @@ -222,3 +227,49 @@ fn test_set_group_admin() {
let group = client.get_group(&group_id);
assert_eq!(group.admin, new_admin);
}

#[test]
fn test_percentage_group_dynamic_pot() {
let (env, admin, client, _token) = setup_env();

let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_client = StellarAssetClient::new(&env, &token_id.address());
let token_reader = TokenClient::new(&env, &token_id.address());

let member1 = Address::generate(&env);
token_client.mint(&admin, &20_000_000);
token_client.mint(&member1, &20_000_000);

let group_id = client.create_group_with_type(
&admin,
&String::from_str(&env, "Percentage Group"),
&token_id.address(),
&1_000, // 10.00% in basis points
&ContributionType::Percentage,
&86400,
&5,
);

client.join_group(&member1, &group_id);
client.set_member_base_amount(&admin, &group_id, &5_000_000);
client.set_member_base_amount(&member1, &group_id, &10_000_000);

assert_eq!(client.get_member_base_amount(&admin, &group_id), 5_000_000);
assert_eq!(
client.get_member_base_amount(&member1, &group_id),
10_000_000
);

client.start_group(&admin, &group_id);
client.contribute(&admin, &group_id);
client.contribute(&member1, &group_id);

let round = client.get_round_status(&group_id, &1);
assert!(round.is_complete);
assert_eq!(round.total_contributed, 1_500_000);

client.distribute_payout(&group_id);
assert_eq!(token_reader.balance(&admin), 21_000_000);
assert_eq!(token_reader.balance(&member1), 19_000_000);
}
10 changes: 10 additions & 0 deletions contracts/sorosave/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
use soroban_sdk::{contracttype, Address, Map, String, Vec};

/// Contribution model used by a savings group.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum ContributionType {
Fixed,
Percentage,
}

/// Status of a savings group throughout its lifecycle.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
Expand All @@ -19,6 +27,7 @@ pub struct SavingsGroup {
pub name: String,
pub admin: Address,
pub token: Address,
pub contribution_type: ContributionType,
pub contribution_amount: i128,
pub cycle_length: u64,
pub max_members: u32,
Expand Down Expand Up @@ -60,5 +69,6 @@ pub enum DataKey {
Group(u64),
Round(u64, u32),
MemberGroups(Address),
MemberBaseAmount(u64, Address),
Dispute(u64),
}