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

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

fn is_token_accepted(group: &SavingsGroup, token: &Address) -> bool {
for accepted_token in group.accepted_tokens.iter() {
if accepted_token == token.clone() {
return true;
}
}
false
}

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

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

let group = storage::get_group(env, group_id).ok_or(ContractError::GroupNotFound)?;
Expand All @@ -25,6 +44,10 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
return Err(ContractError::NotMember);
}

if !is_token_accepted(&group, &token) {
return Err(ContractError::InvalidAmount);
}

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

Expand All @@ -38,7 +61,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
}

// Transfer tokens from member to this contract
let token_client = soroban_sdk::token::Client::new(env, &group.token);
let token_client = soroban_sdk::token::Client::new(env, &token);
token_client.transfer(
&member,
&env.current_contract_address(),
Expand All @@ -48,6 +71,14 @@ 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;
let token_total = round_info
.token_contributions
.get(token.clone())
.unwrap_or(0)
+ group.contribution_amount;
round_info
.token_contributions
.set(token.clone(), token_total);

// Check if all members have contributed
if round_info.contributions.len() == group.members.len() {
Expand All @@ -58,7 +89,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, token, group.contribution_amount),
);

Ok(())
Expand Down
49 changes: 49 additions & 0 deletions contracts/sorosave/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,48 @@ pub fn create_group(
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
let mut accepted_tokens = Vec::new(env);
accepted_tokens.push_back(token);
create_group_with_tokens(
env,
admin,
name,
accepted_tokens,
contribution_amount,
cycle_length,
max_members,
)
}

pub fn create_multi_token_group(
env: &Env,
admin: Address,
name: String,
accepted_tokens: Vec<Address>,
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
create_group_with_tokens(
env,
admin,
name,
accepted_tokens,
contribution_amount,
cycle_length,
max_members,
)
}

fn create_group_with_tokens(
env: &Env,
admin: Address,
name: String,
accepted_tokens: Vec<Address>,
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
admin.require_auth();

Expand All @@ -21,6 +63,11 @@ pub fn create_group(
if max_members < 2 {
return Err(ContractError::InsufficientMembers);
}
if accepted_tokens.is_empty() {
return Err(ContractError::InvalidAmount);
}

let token = accepted_tokens.get(0).unwrap();

let group_id = storage::get_group_counter(env) + 1;
storage::set_group_counter(env, group_id);
Expand All @@ -33,6 +80,7 @@ pub fn create_group(
name,
admin: admin.clone(),
token,
accepted_tokens,
contribution_amount,
cycle_length,
max_members,
Expand Down Expand Up @@ -150,6 +198,7 @@ pub fn start_group(env: &Env, admin: Address, group_id: u64) -> Result<(), Contr
round_number: 1,
recipient: first_recipient,
contributions: Map::new(env),
token_contributions: Map::new(env),
total_contributed: 0,
is_complete: false,
deadline: env.ledger().timestamp() + group.cycle_length,
Expand Down
31 changes: 31 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ impl SoroSaveContract {
)
}

/// Create a savings group that accepts any token in the provided allowlist.
pub fn create_multi_token_group(
env: Env,
admin: Address,
name: String,
accepted_tokens: Vec<Address>,
contribution_amount: i128,
cycle_length: u64,
max_members: u32,
) -> Result<u64, ContractError> {
group::create_multi_token_group(
&env,
admin,
name,
accepted_tokens,
contribution_amount,
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)
Expand Down Expand Up @@ -81,6 +102,16 @@ impl SoroSaveContract {
contribution::contribute(&env, member, group_id)
}

/// Contribute to the current round using an accepted token.
pub fn contribute_with_token(
env: Env,
member: Address,
group_id: u64,
token: Address,
) -> Result<(), ContractError> {
contribution::contribute_with_token(&env, member, group_id, token)
}

/// Get the status of a specific round.
pub fn get_round_status(
env: Env,
Expand Down
25 changes: 18 additions & 7 deletions contracts/sorosave/src/payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,23 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError>
return Err(ContractError::RoundNotComplete);
}

// Transfer the pot to the round's recipient
let token_client = soroban_sdk::token::Client::new(env, &group.token);
token_client.transfer(
&env.current_contract_address(),
&round_info.recipient,
&round_info.total_contributed,
);
// Transfer each token pot to the round's recipient.
let contract_address = env.current_contract_address();
if round_info.token_contributions.is_empty() {
let token_client = soroban_sdk::token::Client::new(env, &group.token);
token_client.transfer(
&contract_address,
&round_info.recipient,
&round_info.total_contributed,
);
} else {
for (token, amount) in round_info.token_contributions.iter() {
if amount > 0 {
let token_client = soroban_sdk::token::Client::new(env, &token);
token_client.transfer(&contract_address, &round_info.recipient, &amount);
}
}
}

env.events().publish(
(crate::symbol_short!("payout"),),
Expand All @@ -50,6 +60,7 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError>
round_number: group.current_round,
recipient: next_recipient,
contributions: Map::new(env),
token_contributions: Map::new(env),
total_contributed: 0,
is_complete: false,
deadline: env.ledger().timestamp() + group.cycle_length,
Expand Down
122 changes: 120 additions & 2 deletions contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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, Vec,
};

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

fn setup_env() -> (Env, Address, SoroSaveContractClient<'static>, Address) {
let env = Env::default();
Expand Down Expand Up @@ -46,6 +50,9 @@ fn test_create_group() {

let group = client.get_group(&group_id);
assert_eq!(group.admin, admin);
assert_eq!(group.token, token);
assert_eq!(group.accepted_tokens.len(), 1);
assert_eq!(group.accepted_tokens.get(0).unwrap(), token);
assert_eq!(group.contribution_amount, 1_000_000);
assert_eq!(group.max_members, 5);
assert_eq!(group.status, GroupStatus::Forming);
Expand Down Expand Up @@ -171,6 +178,117 @@ fn test_member_groups() {
assert_eq!(groups.get(1).unwrap(), group2);
}

#[test]
fn test_multi_token_group_rejects_empty_allowlist() {
let (env, admin, client, _) = setup_env();
let accepted_tokens = Vec::new(&env);

assert_eq!(
client.try_create_multi_token_group(
&admin,
&String::from_str(&env, "Empty Token Group"),
&accepted_tokens,
&1_000_000,
&86400,
&5,
),
Err(Ok(ContractError::InvalidAmount))
);
}

#[test]
fn test_multi_token_contribution_flow() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let member1 = Address::generate(&env);
let contract_id = env.register(SoroSaveContract, (&admin,));
let client = SoroSaveContractClient::new(&env, &contract_id);

let token_a_admin = Address::generate(&env);
let token_a_id = env.register_stellar_asset_contract_v2(token_a_admin);
let token_a = token_a_id.address();
let token_a_sac = StellarAssetClient::new(&env, &token_a);
token_a_sac.mint(&admin, &10_000_000);

let token_b_admin = Address::generate(&env);
let token_b_id = env.register_stellar_asset_contract_v2(token_b_admin);
let token_b = token_b_id.address();
let token_b_sac = StellarAssetClient::new(&env, &token_b);
token_b_sac.mint(&member1, &10_000_000);

let mut accepted_tokens = Vec::new(&env);
accepted_tokens.push_back(token_a.clone());
accepted_tokens.push_back(token_b.clone());

let group_id = client.create_multi_token_group(
&admin,
&String::from_str(&env, "Multi Token Group"),
&accepted_tokens,
&1_000_000,
&86400,
&5,
);
let group = client.get_group(&group_id);
assert_eq!(group.token, token_a);
assert_eq!(group.accepted_tokens.len(), 2);

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

client.contribute_with_token(&admin, &group_id, &token_a);
client.contribute_with_token(&member1, &group_id, &token_b);

let round = client.get_round_status(&group_id, &1);
assert!(round.is_complete);
assert_eq!(round.total_contributed, 2_000_000);
assert_eq!(
round.token_contributions.get(token_a.clone()).unwrap(),
1_000_000
);
assert_eq!(
round.token_contributions.get(token_b.clone()).unwrap(),
1_000_000
);

client.distribute_payout(&group_id);

let token_a_client = TokenClient::new(&env, &token_a);
let token_b_client = TokenClient::new(&env, &token_b);
assert_eq!(token_a_client.balance(&admin), 10_000_000);
assert_eq!(token_b_client.balance(&admin), 1_000_000);
assert_eq!(token_b_client.balance(&member1), 9_000_000);
}

#[test]
fn test_multi_token_contribution_rejects_unaccepted_token() {
let (env, admin, client, token) = setup_env();
let member1 = Address::generate(&env);
let unaccepted_token_admin = Address::generate(&env);
let unaccepted_token_id = env.register_stellar_asset_contract_v2(unaccepted_token_admin);
let unaccepted_token = unaccepted_token_id.address();

let mut accepted_tokens = Vec::new(&env);
accepted_tokens.push_back(token);

let group_id = client.create_multi_token_group(
&admin,
&String::from_str(&env, "Rejected Token Group"),
&accepted_tokens,
&1_000_000,
&86400,
&5,
);
client.join_group(&member1, &group_id);
client.start_group(&admin, &group_id);

assert_eq!(
client.try_contribute_with_token(&admin, &group_id, &unaccepted_token),
Err(Ok(ContractError::InvalidAmount))
);
}

#[test]
fn test_pause_resume_group() {
let (env, admin, client, token) = setup_env();
Expand Down
2 changes: 2 additions & 0 deletions contracts/sorosave/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct SavingsGroup {
pub name: String,
pub admin: Address,
pub token: Address,
pub accepted_tokens: Vec<Address>,
pub contribution_amount: i128,
pub cycle_length: u64,
pub max_members: u32,
Expand All @@ -37,6 +38,7 @@ pub struct RoundInfo {
pub round_number: u32,
pub recipient: Address,
pub contributions: Map<Address, bool>,
pub token_contributions: Map<Address, i128>,
pub total_contributed: i128,
pub is_complete: bool,
pub deadline: u64,
Expand Down