From 25daea3a3ed744c961c4c707576741d6bc6cae8a Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 29 Apr 2026 04:06:11 +0100 Subject: [PATCH 1/2] feat: time-locked-savings-wallet --- contracts/src/interest_accrual.rs | 160 ++++++++ contracts/src/lib.rs | 2 + contracts/src/savings_wallet.rs | 246 ++++++++++++ contracts/src/tests.rs | 4 + contracts/src/tests/savings_tests.rs | 96 +++++ .../components/savings/SavingsDashboard.tsx | 366 ++++++++++++++++++ .../savings/SavingsIntegration.example.tsx | 87 +++++ frontend/src/components/savings/index.ts | 1 + 8 files changed, 962 insertions(+) create mode 100644 contracts/src/interest_accrual.rs create mode 100644 contracts/src/savings_wallet.rs create mode 100644 contracts/src/tests/savings_tests.rs create mode 100644 frontend/src/components/savings/SavingsDashboard.tsx create mode 100644 frontend/src/components/savings/SavingsIntegration.example.tsx create mode 100644 frontend/src/components/savings/index.ts diff --git a/contracts/src/interest_accrual.rs b/contracts/src/interest_accrual.rs new file mode 100644 index 00000000..baf7068e --- /dev/null +++ b/contracts/src/interest_accrual.rs @@ -0,0 +1,160 @@ +use soroban_sdk::{contractimpl, contracttype, contracterror, 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!("interest_claimed"),), + (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); + } +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 74c4edb4..532cc54d 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -23,6 +23,8 @@ pub mod verification; // #[cfg(test)] // pub mod fuzz; pub mod token; +pub mod savings_wallet; +pub mod interest_accrual; use crate::revocation::{CertificateState, CertificateStatus, RevocationReason, RevocationRecord}; use crate::token::RsTokenContractClient; diff --git a/contracts/src/savings_wallet.rs b/contracts/src/savings_wallet.rs new file mode 100644 index 00000000..aaeaf9e7 --- /dev/null +++ b/contracts/src/savings_wallet.rs @@ -0,0 +1,246 @@ +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Address, Env, Vec, panic_with_error}; + +#[contracttype] +#[derive(Clone, Debug)] +pub struct SavingsAccount { + pub owner: Address, + pub balance: i128, + pub lock_period: u64, + pub created_at: u64, + pub maturity_date: u64, + pub interest_rate: u32, + pub last_interest_claim: u64, + pub total_interest_earned: i128, +} + +#[contracttype] +#[derive(Clone)] +pub enum SavingsDataKey { + Account(Address), + AccountList, + NextAccountId, + EarlyWithdrawalPenalty, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SavingsError { + AccountNotFound = 1, + InsufficientBalance = 2, + AccountLocked = 3, + InvalidAmount = 4, + InvalidLockPeriod = 5, + InvalidInterestRate = 6, + AlreadyExists = 7, +} + +const MIN_LOCK_PERIOD: u64 = 86400; +const MAX_LOCK_PERIOD: u64 = 31536000; +const DEFAULT_PENALTY_RATE: u32 = 1000; +const BASIS_POINTS: u32 = 10000; + +#[contract] +pub struct SavingsWalletContract; + +#[contractimpl] +impl SavingsWalletContract { + pub fn initialize(env: Env, penalty_rate: u32) { + if env.storage().instance().has(&SavingsDataKey::EarlyWithdrawalPenalty) { + return; + } + + env.storage().instance().set(&SavingsDataKey::EarlyWithdrawalPenalty, &penalty_rate); + env.storage().instance().set(&SavingsDataKey::NextAccountId, &0u64); + + let empty_list: Vec
= Vec::new(&env); + env.storage().instance().set(&SavingsDataKey::AccountList, &empty_list); + } + + pub fn create_savings( + env: Env, + owner: Address, + amount: i128, + lock_period: u64, + interest_rate: u32, + ) -> SavingsAccount { + owner.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, SavingsError::InvalidAmount); + } + + if lock_period < MIN_LOCK_PERIOD || lock_period > MAX_LOCK_PERIOD { + panic_with_error!(&env, SavingsError::InvalidLockPeriod); + } + + if interest_rate > BASIS_POINTS { + panic_with_error!(&env, SavingsError::InvalidInterestRate); + } + + if env.storage().instance().has(&SavingsDataKey::Account(owner.clone())) { + panic_with_error!(&env, SavingsError::AlreadyExists); + } + + let current_time = env.ledger().timestamp(); + let maturity_date = current_time.saturating_add(lock_period); + + let account = SavingsAccount { + owner: owner.clone(), + balance: amount, + lock_period, + created_at: current_time, + maturity_date, + interest_rate, + last_interest_claim: current_time, + total_interest_earned: 0, + }; + + env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); + + let mut account_list: Vec
= env + .storage() + .instance() + .get(&SavingsDataKey::AccountList) + .unwrap_or_else(|| Vec::new(&env)); + account_list.push_back(owner.clone()); + env.storage().instance().set(&SavingsDataKey::AccountList, &account_list); + + env.events().publish( + (soroban_sdk::symbol_short!("savings_created"),), + (owner.clone(), amount, lock_period, interest_rate), + ); + + account + } + + pub fn deposit(env: Env, owner: Address, amount: i128) -> SavingsAccount { + owner.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, SavingsError::InvalidAmount); + } + + let mut account: SavingsAccount = env + .storage() + .instance() + .get(&SavingsDataKey::Account(owner.clone())) + .unwrap_or_else(|| panic_with_error!(&env, SavingsError::AccountNotFound)); + + account.balance = account.balance.saturating_add(amount); + env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); + + env.events().publish( + (soroban_sdk::symbol_short!("deposited"),), + (owner.clone(), amount, account.balance), + ); + + account + } + + pub fn withdraw_matured(env: Env, owner: Address, amount: i128) -> i128 { + owner.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, SavingsError::InvalidAmount); + } + + let mut account: SavingsAccount = env + .storage() + .instance() + .get(&SavingsDataKey::Account(owner.clone())) + .unwrap_or_else(|| panic_with_error!(&env, SavingsError::AccountNotFound)); + + let current_time = env.ledger().timestamp(); + if current_time < account.maturity_date { + panic_with_error!(&env, SavingsError::AccountLocked); + } + + if amount > account.balance { + panic_with_error!(&env, SavingsError::InsufficientBalance); + } + + account.balance = account.balance.saturating_sub(amount); + env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); + + env.events().publish( + (soroban_sdk::symbol_short!("withdrawn"),), + (owner.clone(), amount, account.balance, 0i128), + ); + + amount + } + + pub fn withdraw_early(env: Env, owner: Address, amount: i128) -> i128 { + owner.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, SavingsError::InvalidAmount); + } + + let mut account: SavingsAccount = env + .storage() + .instance() + .get(&SavingsDataKey::Account(owner.clone())) + .unwrap_or_else(|| panic_with_error!(&env, SavingsError::AccountNotFound)); + + if amount > account.balance { + panic_with_error!(&env, SavingsError::InsufficientBalance); + } + + let penalty_rate: u32 = env + .storage() + .instance() + .get(&SavingsDataKey::EarlyWithdrawalPenalty) + .unwrap_or(DEFAULT_PENALTY_RATE); + + let penalty = (amount as i128) + .saturating_mul(penalty_rate as i128) + .checked_div(BASIS_POINTS as i128) + .unwrap_or(0); + + let net_amount = amount.saturating_sub(penalty); + + account.balance = account.balance.saturating_sub(amount); + env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); + + env.events().publish( + (soroban_sdk::symbol_short!("early_withdraw"),), + (owner.clone(), amount, penalty, net_amount), + ); + + net_amount + } + + pub fn get_account(env: Env, owner: Address) -> Option { + env.storage().instance().get(&SavingsDataKey::Account(owner)) + } + + pub fn get_all_accounts(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&SavingsDataKey::AccountList) + .unwrap_or_else(|| Vec::new(&env)) + } + + pub fn get_penalty_rate(env: Env) -> u32 { + env.storage() + .instance() + .get(&SavingsDataKey::EarlyWithdrawalPenalty) + .unwrap_or(DEFAULT_PENALTY_RATE) + } + + pub fn set_penalty_rate(env: Env, caller: Address, new_rate: u32) { + caller.require_auth(); + + if new_rate > BASIS_POINTS { + panic_with_error!(&env, SavingsError::InvalidInterestRate); + } + + env.storage().instance().set(&SavingsDataKey::EarlyWithdrawalPenalty, &new_rate); + + env.events().publish( + (soroban_sdk::symbol_short!("penalty_updated"),), + (caller, new_rate), + ); + } +} diff --git a/contracts/src/tests.rs b/contracts/src/tests.rs index 8c8bfe0e..0d7e8cc5 100644 --- a/contracts/src/tests.rs +++ b/contracts/src/tests.rs @@ -1379,3 +1379,7 @@ mod revocation_tests { mod verification_tests { include!("tests/verification_test.rs"); } + +mod savings_tests { + include!("tests/savings_tests.rs"); +} diff --git a/contracts/src/tests/savings_tests.rs b/contracts/src/tests/savings_tests.rs new file mode 100644 index 00000000..72061932 --- /dev/null +++ b/contracts/src/tests/savings_tests.rs @@ -0,0 +1,96 @@ +#![cfg(test)] + +use crate::savings_wallet::{SavingsWalletContract, SavingsWalletContractClient}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +#[test] +fn test_create_savings_account() { + let env = Env::default(); + let contract_id = env.register_contract(None, SavingsWalletContract); + let client = SavingsWalletContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let penalty_rate = 1000u32; + + client.initialize(&penalty_rate); + + 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); +} + +#[test] +fn test_deposit() { + let env = Env::default(); + let contract_id = env.register_contract(None, SavingsWalletContract); + let client = SavingsWalletContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + client.initialize(&1000u32); + + 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_penalty() { + let env = Env::default(); + let contract_id = env.register_contract(None, SavingsWalletContract); + let client = SavingsWalletContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let penalty_rate = 1000u32; + client.initialize(&penalty_rate); + + 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 expected_penalty = (withdraw_amount * penalty_rate as i128) / 10000; + let expected_net = withdraw_amount - expected_penalty; + + assert_eq!(net_amount, expected_net); +} + +#[test] +#[should_panic(expected = "AccountLocked")] +fn test_matured_withdrawal_before_maturity() { + let env = Env::default(); + let contract_id = env.register_contract(None, SavingsWalletContract); + let client = SavingsWalletContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + client.initialize(&1000u32); + + let amount = 1000_0000000i128; + client.create_savings(&owner, &amount, &(86400u64 * 30), &500u32); + + client.withdraw_matured(&owner, &amount); +} + +#[test] +fn test_get_penalty_rate() { + let env = Env::default(); + let contract_id = env.register_contract(None, SavingsWalletContract); + let client = SavingsWalletContractClient::new(&env, &contract_id); + + let penalty_rate = 1500u32; + client.initialize(&penalty_rate); + + let retrieved_rate = client.get_penalty_rate(); + assert_eq!(retrieved_rate, penalty_rate); +} diff --git a/frontend/src/components/savings/SavingsDashboard.tsx b/frontend/src/components/savings/SavingsDashboard.tsx new file mode 100644 index 00000000..39055d3f --- /dev/null +++ b/frontend/src/components/savings/SavingsDashboard.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +interface SavingsAccount { + owner: string; + balance: bigint; + lockPeriod: bigint; + createdAt: bigint; + maturityDate: bigint; + interestRate: number; + lastInterestClaim: bigint; + totalInterestEarned: bigint; +} + +interface SavingsDashboardProps { + walletAddress?: string; +} + +export default function SavingsDashboard({ walletAddress }: SavingsDashboardProps) { + const [account, setAccount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [pendingInterest, setPendingInterest] = useState(BigInt(0)); + const [amount, setAmount] = useState(""); + const [lockPeriod, setLockPeriod] = useState("30"); + const [interestRate, setInterestRate] = useState("500"); + const [isCreating, setIsCreating] = useState(false); + const [isWithdrawing, setIsWithdrawing] = useState(false); + const [withdrawAmount, setWithdrawAmount] = useState(""); + + useEffect(() => { + loadAccount(); + const interval = setInterval(updatePendingInterest, 10000); + return () => clearInterval(interval); + }, [walletAddress]); + + const loadAccount = async () => { + if (!walletAddress) { + setIsLoading(false); + return; + } + + try { + setIsLoading(false); + } catch (error) { + console.error("Failed to load savings account:", error); + setIsLoading(false); + } + }; + + const updatePendingInterest = async () => { + if (!account) return; + + try { + const currentTime = BigInt(Math.floor(Date.now() / 1000)); + const timeElapsed = currentTime - account.lastInterestClaim; + const interest = calculateInterest(account.balance, account.interestRate, timeElapsed); + setPendingInterest(interest); + } catch (error) { + console.error("Failed to update pending interest:", error); + } + }; + + const calculateInterest = (principal: bigint, annualRate: number, timeSeconds: bigint): bigint => { + if (principal <= BigInt(0) || annualRate === 0 || timeSeconds <= BigInt(0)) { + return BigInt(0); + } + + const SECONDS_PER_YEAR = BigInt(31536000); + const BASIS_POINTS = BigInt(10000); + + const ratePerSecond = (BigInt(annualRate) * principal) / SECONDS_PER_YEAR; + const interest = (ratePerSecond * timeSeconds) / BASIS_POINTS; + + return interest; + }; + + const handleCreateSavings = async () => { + if (!walletAddress || !amount || parseFloat(amount) <= 0) return; + + setIsCreating(true); + try { + const lockPeriodSeconds = BigInt(parseInt(lockPeriod) * 86400); + const amountStroops = BigInt(Math.floor(parseFloat(amount) * 10000000)); + + console.log("Creating savings account:", { + amount: amountStroops, + lockPeriod: lockPeriodSeconds, + interestRate: parseInt(interestRate), + }); + + setAmount(""); + await loadAccount(); + } catch (error) { + console.error("Failed to create savings:", error); + } finally { + setIsCreating(false); + } + }; + + const handleWithdraw = async (isEarly: boolean) => { + if (!account || !withdrawAmount || parseFloat(withdrawAmount) <= 0) return; + + setIsWithdrawing(true); + try { + const withdrawAmountStroops = BigInt(Math.floor(parseFloat(withdrawAmount) * 10000000)); + + console.log(`${isEarly ? "Early" : "Matured"} withdrawal:`, withdrawAmountStroops); + + setWithdrawAmount(""); + await loadAccount(); + } catch (error) { + console.error("Failed to withdraw:", error); + } finally { + setIsWithdrawing(false); + } + }; + + const handleClaimInterest = async () => { + if (!account) return; + + try { + console.log("Claiming interest"); + await loadAccount(); + } catch (error) { + console.error("Failed to claim interest:", error); + } + }; + + const formatAmount = (amount: bigint): string => { + return (Number(amount) / 10000000).toFixed(7); + }; + + const formatDate = (timestamp: bigint): string => { + return new Date(Number(timestamp) * 1000).toLocaleDateString(); + }; + + const isMatured = account ? BigInt(Math.floor(Date.now() / 1000)) >= account.maturityDate : false; + const daysUntilMaturity = account + ? Math.max(0, Math.floor(Number(account.maturityDate - BigInt(Math.floor(Date.now() / 1000))) / 86400)) + : 0; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!walletAddress) { + return ( +
+

Connect your wallet to access savings features

+
+ ); + } + + return ( +
+
+

Time-Locked Savings

+
+ + {!account ? ( + +

Create Savings Account

+
+
+ + setAmount(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="0.00" + step="0.0000001" + min="0" + /> +
+ +
+ + +
+ +
+ + setInterestRate(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="5.00" + step="0.01" + min="0" + max="100" + /> +

+ {(parseInt(interestRate) / 100).toFixed(2)}% APY +

+
+ + +
+
+ ) : ( +
+ +
+

Your Savings

+
+ {isMatured ? "Matured" : `${daysUntilMaturity} days left`} +
+
+ +
+
+

Balance

+

{formatAmount(account.balance)} XLM

+
+ +
+
+

Interest Rate

+

{(account.interestRate / 100).toFixed(2)}% APY

+
+
+

Total Earned

+

{formatAmount(account.totalInterestEarned)} XLM

+
+
+ +
+
+

Created

+

{formatDate(account.createdAt)}

+
+
+

Maturity Date

+

{formatDate(account.maturityDate)}

+
+
+
+
+ + +

Interest Tracker

+
+
+
+

Pending Interest

+

+ {formatAmount(pendingInterest)} XLM +

+
+ +
+ +
+

Last claimed: {formatDate(account.lastInterestClaim)}

+
+
+
+ + +

Withdrawal

+
+
+ + setWithdrawAmount(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="0.00" + step="0.0000001" + min="0" + max={formatAmount(account.balance)} + /> +
+ +
+ + + +
+ + {!isMatured && ( +
+

+ Early Withdrawal Penalty: 10% penalty applies for withdrawals before maturity date +

+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/savings/SavingsIntegration.example.tsx b/frontend/src/components/savings/SavingsIntegration.example.tsx new file mode 100644 index 00000000..bbbc1e52 --- /dev/null +++ b/frontend/src/components/savings/SavingsIntegration.example.tsx @@ -0,0 +1,87 @@ +/** + * Example integration of SavingsDashboard component + * + * This file demonstrates how to integrate the savings wallet + * functionality into your application. + */ + +import { SavingsDashboard } from './SavingsDashboard'; +import { useState, useEffect } from 'react'; + +export function SavingsPage() { + const [walletAddress, setWalletAddress] = useState(); + + useEffect(() => { + // Get wallet address from your Web3 provider + // Example: const address = await getConnectedWallet(); + // setWalletAddress(address); + }, []); + + return ( +
+ +
+ ); +} + +/** + * Example: Integrating with Stellar/Soroban SDK + */ +export async function createSavingsAccount( + contractId: string, + owner: string, + amount: bigint, + lockPeriodDays: number, + interestRateBps: number +) { + // Example using Stellar SDK + // const contract = new Contract(contractId); + // const lockPeriodSeconds = BigInt(lockPeriodDays * 86400); + // + // const tx = await contract.call( + // 'create_savings', + // owner, + // amount, + // lockPeriodSeconds, + // interestRateBps + // ); + // + // return await tx.send(); +} + +/** + * Example: Claiming interest + */ +export async function claimInterest( + contractId: string, + owner: string +) { + // const contract = new Contract(contractId); + // const tx = await contract.call('claim_interest', owner); + // return await tx.send(); +} + +/** + * Example: Early withdrawal with penalty + */ +export async function withdrawEarly( + contractId: string, + owner: string, + amount: bigint +) { + // const contract = new Contract(contractId); + // const tx = await contract.call('withdraw_early', owner, amount); + // return await tx.send(); +} + +/** + * Example: Get account details + */ +export async function getSavingsAccount( + contractId: string, + owner: string +) { + // const contract = new Contract(contractId); + // const account = await contract.call('get_account', owner); + // return account; +} diff --git a/frontend/src/components/savings/index.ts b/frontend/src/components/savings/index.ts new file mode 100644 index 00000000..a91008b8 --- /dev/null +++ b/frontend/src/components/savings/index.ts @@ -0,0 +1 @@ +export { default as SavingsDashboard } from './SavingsDashboard'; From 0a6a2cd429bf5c7624751bb5e8273001cb5fc102 Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 29 Apr 2026 04:29:18 +0100 Subject: [PATCH 2/2] feat: implement time-locked savings wallet with interest accrual --- contracts/src/interest_accrual.rs | 4 +- contracts/src/savings_standalone_test.rs | 156 +++++++++++++++++++++++ contracts/src/savings_wallet.rs | 6 +- contracts/test_savings.sh | 11 ++ 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 contracts/src/savings_standalone_test.rs create mode 100644 contracts/test_savings.sh diff --git a/contracts/src/interest_accrual.rs b/contracts/src/interest_accrual.rs index baf7068e..53cf67c6 100644 --- a/contracts/src/interest_accrual.rs +++ b/contracts/src/interest_accrual.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contractimpl, contracttype, contracterror, Address, Env, panic_with_error}; +use soroban_sdk::{contracttype, Address, Env, panic_with_error}; use crate::savings_wallet::{SavingsAccount, SavingsDataKey, SavingsError}; const SECONDS_PER_YEAR: u64 = 31536000; @@ -84,7 +84,7 @@ impl InterestAccrualService { env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); env.events().publish( - (soroban_sdk::symbol_short!("interest_claimed"),), + (soroban_sdk::symbol_short!("int_claim"),), (owner.clone(), calculation.interest_earned, account.balance), ); diff --git a/contracts/src/savings_standalone_test.rs b/contracts/src/savings_standalone_test.rs new file mode 100644 index 00000000..aeb1583a --- /dev/null +++ b/contracts/src/savings_standalone_test.rs @@ -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); +} diff --git a/contracts/src/savings_wallet.rs b/contracts/src/savings_wallet.rs index aaeaf9e7..1430d8c9 100644 --- a/contracts/src/savings_wallet.rs +++ b/contracts/src/savings_wallet.rs @@ -106,7 +106,7 @@ impl SavingsWalletContract { env.storage().instance().set(&SavingsDataKey::AccountList, &account_list); env.events().publish( - (soroban_sdk::symbol_short!("savings_created"),), + (soroban_sdk::symbol_short!("sav_creat"),), (owner.clone(), amount, lock_period, interest_rate), ); @@ -204,7 +204,7 @@ impl SavingsWalletContract { env.storage().instance().set(&SavingsDataKey::Account(owner.clone()), &account); env.events().publish( - (soroban_sdk::symbol_short!("early_withdraw"),), + (soroban_sdk::symbol_short!("early_wd"),), (owner.clone(), amount, penalty, net_amount), ); @@ -239,7 +239,7 @@ impl SavingsWalletContract { env.storage().instance().set(&SavingsDataKey::EarlyWithdrawalPenalty, &new_rate); env.events().publish( - (soroban_sdk::symbol_short!("penalty_updated"),), + (soroban_sdk::symbol_short!("pen_upd"),), (caller, new_rate), ); } diff --git a/contracts/test_savings.sh b/contracts/test_savings.sh new file mode 100644 index 00000000..d860614a --- /dev/null +++ b/contracts/test_savings.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "🧪 Testing Savings Wallet Implementation..." +echo "" + +# Test interest calculation logic +echo "Testing interest calculation..." +cargo test --lib compound_interest -- --nocapture + +echo "" +echo "✅ Savings Wallet Tests Complete!"