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

use crate::errors::ContractError;
use crate::r#yield as yield_source;
use crate::storage;
use crate::types::{GroupStatus, RoundInfo};

Expand Down Expand Up @@ -52,6 +53,7 @@ pub fn contribute(env: &Env, member: Address, group_id: u64) -> Result<(), Contr
// Check if all members have contributed
if round_info.contributions.len() == group.members.len() {
round_info.is_complete = true;
yield_source::deposit_idle_funds(env, &group, &round_info)?;
}

storage::set_round(env, group_id, &round_info);
Expand Down
1 change: 1 addition & 0 deletions contracts/sorosave/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub fn create_group(
contribution_amount,
cycle_length,
max_members,
yield_rate_bps: 0,
members,
payout_order: Vec::new(env),
current_round: 0,
Expand Down
29 changes: 29 additions & 0 deletions contracts/sorosave/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod group;
mod payout;
mod storage;
mod types;
mod r#yield;

pub use errors::ContractError;
pub use types::*;
Expand Down Expand Up @@ -100,6 +101,34 @@ impl SoroSaveContract {
contribution::has_contributed(&env, member, group_id, round)
}

/// Configure the fixed yield rate used by the current yield-source integration.
pub fn set_yield_rate(
env: Env,
admin: Address,
group_id: u64,
yield_rate_bps: u32,
) -> Result<(), ContractError> {
admin.require_auth();

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

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

env.events().publish(
(crate::symbol_short!("yieldcfg"),),
(group_id, yield_rate_bps),
);

Ok(())
}

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

/// Distribute the pot to the current round's recipient. Anyone can call this
Expand Down
11 changes: 5 additions & 6 deletions contracts/sorosave/src/payout.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use soroban_sdk::{Address, Env, Map, Vec};

use crate::errors::ContractError;
use crate::r#yield as yield_source;
use crate::storage;
use crate::types::{GroupStatus, RoundInfo};

Expand All @@ -18,21 +19,19 @@ pub fn distribute_payout(env: &Env, group_id: u64) -> Result<(), ContractError>
return Err(ContractError::RoundNotComplete);
}

let payout_amount = yield_source::withdraw_for_payout(env, &group, &round_info)?;

// 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,
&payout_amount,
);

env.events().publish(
(crate::symbol_short!("payout"),),
(
group_id,
round_info.recipient.clone(),
round_info.total_contributed,
),
(group_id, round_info.recipient.clone(), payout_amount),
);

// Advance to next round or complete the group
Expand Down
24 changes: 23 additions & 1 deletion contracts/sorosave/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use soroban_sdk::{Address, Env, Vec};

use crate::types::{DataKey, Dispute, RoundInfo, SavingsGroup};
use crate::types::{DataKey, Dispute, RoundInfo, SavingsGroup, YieldPosition};

const INSTANCE_TTL_THRESHOLD: u32 = 100;
const INSTANCE_TTL_EXTEND: u32 = 500;
Expand Down Expand Up @@ -122,6 +122,28 @@ pub fn remove_dispute(env: &Env, group_id: u64) {
env.storage().persistent().remove(&key);
}

// --- Yield Positions ---

pub fn get_yield_position(env: &Env, group_id: u64, round: u32) -> Option<YieldPosition> {
let key = DataKey::YieldPosition(group_id, round);
let result = env.storage().persistent().get(&key);
if result.is_some() {
extend_persistent_ttl(env, &key);
}
result
}

pub fn set_yield_position(env: &Env, group_id: u64, round: u32, position: &YieldPosition) {
let key = DataKey::YieldPosition(group_id, round);
env.storage().persistent().set(&key, position);
extend_persistent_ttl(env, &key);
}

pub fn remove_yield_position(env: &Env, group_id: u64, round: u32) {
let key = DataKey::YieldPosition(group_id, round);
env.storage().persistent().remove(&key);
}

// --- TTL Management ---

fn extend_instance_ttl(env: &Env) {
Expand Down
37 changes: 37 additions & 0 deletions contracts/sorosave/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ fn test_create_group() {
assert_eq!(group.admin, admin);
assert_eq!(group.contribution_amount, 1_000_000);
assert_eq!(group.max_members, 5);
assert_eq!(group.yield_rate_bps, 0);
assert_eq!(group.status, GroupStatus::Forming);
assert_eq!(group.members.len(), 1);
}
Expand Down Expand Up @@ -222,3 +223,39 @@ fn test_set_group_admin() {
let group = client.get_group(&group_id);
assert_eq!(group.admin, new_admin);
}

#[test]
fn test_yield_added_to_payout_when_configured() {
let (env, admin, client, _token) = setup_env();
let mint_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(mint_admin.clone());
let token = token_id.address();
let token_sac = StellarAssetClient::new(&env, &token);

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

let group_id = client.create_group(
&admin,
&String::from_str(&env, "Yield Test"),
&token,
&1_000_000,
&86400,
&5,
);
client.join_group(&member1, &group_id);
client.set_yield_rate(&admin, &group_id, &1_000);
client.start_group(&admin, &group_id);

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

token_sac.mint(&client.address, &200_000);

let admin_balance_before = soroban_sdk::token::Client::new(&env, &token).balance(&admin);
client.distribute_payout(&group_id);
let admin_balance_after = soroban_sdk::token::Client::new(&env, &token).balance(&admin);

assert_eq!(admin_balance_after - admin_balance_before, 2_200_000);
}
11 changes: 11 additions & 0 deletions contracts/sorosave/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct SavingsGroup {
pub contribution_amount: i128,
pub cycle_length: u64,
pub max_members: u32,
pub yield_rate_bps: u32,
pub members: Vec<Address>,
pub payout_order: Vec<Address>,
pub current_round: u32,
Expand Down Expand Up @@ -51,6 +52,15 @@ pub struct Dispute {
pub raised_at: u64,
}

/// Tracks idle funds deposited into a yield source for a round.
#[contracttype]
#[derive(Clone, Debug)]
pub struct YieldPosition {
pub principal: i128,
pub earned_yield: i128,
pub deposited_at: u64,
}

/// Storage keys for all contract data.
#[contracttype]
#[derive(Clone)]
Expand All @@ -61,4 +71,5 @@ pub enum DataKey {
Round(u64, u32),
MemberGroups(Address),
Dispute(u64),
YieldPosition(u64, u32),
}
90 changes: 90 additions & 0 deletions contracts/sorosave/src/yield.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use soroban_sdk::Env;

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

pub trait YieldSource {
fn deposit(
env: &Env,
group_id: u64,
round: u32,
amount: i128,
rate_bps: u32,
) -> Result<(), ContractError>;

fn withdraw(env: &Env, group_id: u64, round: u32) -> Result<i128, ContractError>;
}

pub struct FixedRateYieldSource;

impl YieldSource for FixedRateYieldSource {
fn deposit(
env: &Env,
group_id: u64,
round: u32,
amount: i128,
rate_bps: u32,
) -> Result<(), ContractError> {
if amount <= 0 || rate_bps == 0 {
return Ok(());
}

let position = YieldPosition {
principal: amount,
earned_yield: amount * rate_bps as i128 / 10_000,
deposited_at: env.ledger().timestamp(),
};
storage::set_yield_position(env, group_id, round, &position);

env.events().publish(
(crate::symbol_short!("yielddep"),),
(group_id, round, amount),
);

Ok(())
}

fn withdraw(env: &Env, group_id: u64, round: u32) -> Result<i128, ContractError> {
let Some(position) = storage::get_yield_position(env, group_id, round) else {
return Ok(0);
};

storage::remove_yield_position(env, group_id, round);
let amount = position.principal + position.earned_yield;

env.events().publish(
(crate::symbol_short!("yieldwd"),),
(group_id, round, amount),
);

Ok(amount)
}
}

pub fn deposit_idle_funds(
env: &Env,
group: &SavingsGroup,
round_info: &RoundInfo,
) -> Result<(), ContractError> {
FixedRateYieldSource::deposit(
env,
group.id,
round_info.round_number,
round_info.total_contributed,
group.yield_rate_bps,
)
}

pub fn withdraw_for_payout(
env: &Env,
group: &SavingsGroup,
round_info: &RoundInfo,
) -> Result<i128, ContractError> {
let yielded_amount = FixedRateYieldSource::withdraw(env, group.id, round_info.round_number)?;
if yielded_amount > 0 {
Ok(yielded_amount)
} else {
Ok(round_info.total_contributed)
}
}