Skip to content
Merged
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
160 changes: 160 additions & 0 deletions contracts/src/interest_accrual.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
use soroban_sdk::{contracttype, Address, Env, panic_with_error};
use crate::savings_wallet::{SavingsAccount, SavingsDataKey, SavingsError};

const SECONDS_PER_YEAR: u64 = 31536000;
const BASIS_POINTS: u32 = 10000;

#[contracttype]
#[derive(Clone, Debug)]
pub struct InterestCalculation {
pub principal: i128,
pub interest_earned: i128,
pub time_elapsed: u64,
pub effective_rate: u32,
}

pub struct InterestAccrualService;

impl InterestAccrualService {
pub fn calculate_accrued_interest(
env: &Env,
account: &SavingsAccount,
) -> InterestCalculation {
let current_time = env.ledger().timestamp();
let time_elapsed = current_time.saturating_sub(account.last_interest_claim);

if time_elapsed == 0 {
return InterestCalculation {
principal: account.balance,
interest_earned: 0,
time_elapsed: 0,
effective_rate: account.interest_rate,
};
}

let interest = Self::compound_interest(
account.balance,
account.interest_rate,
time_elapsed,
);

InterestCalculation {
principal: account.balance,
interest_earned: interest,
time_elapsed,
effective_rate: account.interest_rate,
}
}

pub fn compound_interest(principal: i128, annual_rate: u32, time_seconds: u64) -> i128 {
if principal <= 0 || annual_rate == 0 || time_seconds == 0 {
return 0;
}

let rate_per_second = (annual_rate as i128)
.checked_div(SECONDS_PER_YEAR as i128)
.unwrap_or(0);

let interest = principal
.saturating_mul(rate_per_second)
.saturating_mul(time_seconds as i128)
.checked_div(BASIS_POINTS as i128)
.unwrap_or(0);

interest
}

pub fn claim_interest(env: &Env, owner: &Address) -> i128 {
let mut account: SavingsAccount = env
.storage()
.instance()
.get(&SavingsDataKey::Account(owner.clone()))
.unwrap_or_else(|| panic_with_error!(env, SavingsError::AccountNotFound));

let calculation = Self::calculate_accrued_interest(env, &account);

if calculation.interest_earned <= 0 {
return 0;
}

account.balance = account.balance.saturating_add(calculation.interest_earned);
account.total_interest_earned = account.total_interest_earned.saturating_add(calculation.interest_earned);
account.last_interest_claim = env.ledger().timestamp();

env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account);

env.events().publish(
(soroban_sdk::symbol_short!("int_claim"),),
(owner.clone(), calculation.interest_earned, account.balance),
);

calculation.interest_earned
}

pub fn get_pending_interest(env: &Env, owner: &Address) -> i128 {
let account: SavingsAccount = env
.storage()
.instance()
.get(&SavingsDataKey::Account(owner.clone()))
.unwrap_or_else(|| panic_with_error!(env, SavingsError::AccountNotFound));

let calculation = Self::calculate_accrued_interest(env, &account);
calculation.interest_earned
}

pub fn get_projected_interest(
env: &Env,
owner: &Address,
future_seconds: u64,
) -> i128 {
let account: SavingsAccount = env
.storage()
.instance()
.get(&SavingsDataKey::Account(owner.clone()))
.unwrap_or_else(|| panic_with_error!(env, SavingsError::AccountNotFound));

let current_calculation = Self::calculate_accrued_interest(env, &account);
let future_balance = account.balance.saturating_add(current_calculation.interest_earned);

Self::compound_interest(future_balance, account.interest_rate, future_seconds)
}

pub fn get_apy(annual_rate: u32) -> u32 {
annual_rate
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_compound_interest_calculation() {
let principal = 1000_0000000i128;
let annual_rate = 500u32;
let time_seconds = 31536000u64;

let interest = InterestAccrualService::compound_interest(principal, annual_rate, time_seconds);

assert!(interest > 0);
assert!(interest <= principal.saturating_mul(annual_rate as i128).checked_div(BASIS_POINTS as i128).unwrap_or(0));
}

#[test]
fn test_zero_principal() {
let interest = InterestAccrualService::compound_interest(0, 500, 31536000);
assert_eq!(interest, 0);
}

#[test]
fn test_zero_rate() {
let interest = InterestAccrualService::compound_interest(1000_0000000, 0, 31536000);
assert_eq!(interest, 0);
}

#[test]
fn test_zero_time() {
let interest = InterestAccrualService::compound_interest(1000_0000000, 500, 0);
assert_eq!(interest, 0);
}
}
12 changes: 7 additions & 5 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub mod quadratic_voting;
// #[cfg(test)]
// pub mod fuzz;
pub mod token;
pub mod savings_wallet;
pub mod interest_accrual;
pub mod blogging_platform;
pub mod content_monetization;
pub mod carbon_credit_platform;
Expand Down Expand Up @@ -2262,11 +2264,11 @@ impl CertificateContract {
// --- Job Board Functions ---

pub fn create_job(
env: Env,
employer: Address,
title: String,
description: String,
budget: i128,
env: Env,
employer: Address,
title: String,
description: String,
budget: i128,
milestones: Vec<Milestone>,
required_skills: Vec<String>,
token_addr: Address
Expand Down
156 changes: 156 additions & 0 deletions contracts/src/savings_standalone_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#![cfg(test)]

use crate::savings_wallet::{SavingsWalletContract, SavingsWalletContractClient, SavingsError};
use crate::interest_accrual::InterestAccrualService;
use soroban_sdk::{testutils::Address as _, Address, Env};

fn setup() -> (Env, Address, SavingsWalletContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, SavingsWalletContract);
let client = SavingsWalletContractClient::new(&env, &contract_id);
let owner = Address::generate(&env);

client.initialize(&1000u32);

(env, owner, client)
}

#[test]
fn test_create_and_retrieve_account() {
let (_env, owner, client) = setup();

let amount = 1000_0000000i128;
let lock_period = 86400u64 * 30;
let interest_rate = 500u32;

let account = client.create_savings(&owner, &amount, &lock_period, &interest_rate);

assert_eq!(account.owner, owner);
assert_eq!(account.balance, amount);
assert_eq!(account.lock_period, lock_period);
assert_eq!(account.interest_rate, interest_rate);

let retrieved = client.get_account(&owner).unwrap();
assert_eq!(retrieved.balance, amount);
}

#[test]
fn test_deposit_increases_balance() {
let (_env, owner, client) = setup();

let initial_amount = 1000_0000000i128;
client.create_savings(&owner, &initial_amount, &(86400u64 * 30), &500u32);

let deposit_amount = 500_0000000i128;
let account = client.deposit(&owner, &deposit_amount);

assert_eq!(account.balance, initial_amount + deposit_amount);
}

#[test]
fn test_early_withdrawal_applies_penalty() {
let (_env, owner, client) = setup();

let amount = 1000_0000000i128;
client.create_savings(&owner, &amount, &(86400u64 * 30), &500u32);

let withdraw_amount = 500_0000000i128;
let net_amount = client.withdraw_early(&owner, &withdraw_amount);

let penalty_rate = 1000u32;
let expected_penalty = (withdraw_amount * penalty_rate as i128) / 10000;
let expected_net = withdraw_amount - expected_penalty;

assert_eq!(net_amount, expected_net);
}

#[test]
fn test_penalty_rate_management() {
let (_env, owner, client) = setup();

let initial_rate = client.get_penalty_rate();
assert_eq!(initial_rate, 1000u32);

let new_rate = 1500u32;
client.set_penalty_rate(&owner, &new_rate);

let updated_rate = client.get_penalty_rate();
assert_eq!(updated_rate, new_rate);
}

#[test]
fn test_interest_calculation() {
let principal = 1000_0000000i128;
let annual_rate = 500u32;
let time_seconds = 31536000u64;

let interest = InterestAccrualService::compound_interest(principal, annual_rate, time_seconds);

assert!(interest > 0);
let expected_max = (principal * annual_rate as i128) / 10000;
assert!(interest <= expected_max);
}

#[test]
fn test_zero_values_return_zero_interest() {
assert_eq!(InterestAccrualService::compound_interest(0, 500, 31536000), 0);
assert_eq!(InterestAccrualService::compound_interest(1000_0000000, 0, 31536000), 0);
assert_eq!(InterestAccrualService::compound_interest(1000_0000000, 500, 0), 0);
}

#[test]
fn test_get_all_accounts() {
let (_env, owner, client) = setup();

let accounts_before = client.get_all_accounts();
assert_eq!(accounts_before.len(), 0);

client.create_savings(&owner, &1000_0000000i128, &(86400u64 * 30), &500u32);

let accounts_after = client.get_all_accounts();
assert_eq!(accounts_after.len(), 1);
assert_eq!(accounts_after.get(0).unwrap(), owner);
}

#[test]
#[should_panic]
fn test_cannot_create_duplicate_account() {
let (_env, owner, client) = setup();

client.create_savings(&owner, &1000_0000000i128, &(86400u64 * 30), &500u32);
client.create_savings(&owner, &500_0000000i128, &(86400u64 * 60), &300u32);
}

#[test]
#[should_panic]
fn test_invalid_lock_period_too_short() {
let (_env, owner, client) = setup();

client.create_savings(&owner, &1000_0000000i128, &1000u64, &500u32);
}

#[test]
#[should_panic]
fn test_invalid_lock_period_too_long() {
let (_env, owner, client) = setup();

client.create_savings(&owner, &1000_0000000i128, &(86400u64 * 400), &500u32);
}

#[test]
#[should_panic]
fn test_invalid_interest_rate() {
let (_env, owner, client) = setup();

client.create_savings(&owner, &1000_0000000i128, &(86400u64 * 30), &15000u32);
}

#[test]
#[should_panic]
fn test_insufficient_balance_withdrawal() {
let (_env, owner, client) = setup();

client.create_savings(&owner, &1000_0000000i128, &(86400u64 * 30), &500u32);
client.withdraw_early(&owner, &2000_0000000i128);
}
Loading
Loading