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
15 changes: 15 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
repos:
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
entry: cargo fmt --manifest-path contracts/Cargo.toml --all -- --check
language: system
types: [rust]
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
entry: cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings
language: system
types: [rust]
pass_filenames: false
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Kolo Savings Platform - Smart Contract

This repository contains the core **Soroban Smart Contract** for the Kolo Savings Platform. Kolo is designed to facilitate Ajo/Esusu (rotational savings) directly on the Stellar blockchain, providing a trustless, transparent, and secure environment for community savings groups.

## Overview

The smart contract ensures strict adherence to rotational savings rules:
Expand Down Expand Up @@ -45,6 +45,17 @@ To build and test the contract, you need to install Rust and the Soroban CLI:
cargo install --locked soroban-cli
```

## Pre-commit Hooks

To ensure code quality, this repository uses pre-commit hooks to automatically format and lint Rust code before committing.

To install the hooks, run:

```bash
pip install pre-commit
pre-commit install
```

## Build

Compile the smart contract into a WebAssembly (`.wasm`) file:
Expand Down
2 changes: 1 addition & 1 deletion contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
soroban-sdk = "21.0.0"

[dev_dependencies]
[dev-dependencies]
soroban-sdk = { version = "21.0.0", features = ["testutils"] }

[profile.release]
Expand Down
158 changes: 121 additions & 37 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,19 @@ impl KoloSavingsContract {
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Token, &token);
env.storage().instance().set(&DataKey::Name, &name);
env.storage().instance().set(&DataKey::ContributionAmount, &contribution_amount);

let empty_members: Vec<Address> = Vec::new(&env);
env.storage().instance().set(&DataKey::Members, &empty_members);
env.storage()
.instance()
.set(&DataKey::ContributionAmount, &contribution_amount);

env.events().publish((symbol_short!("init"),), (admin, token, name, contribution_amount));
let empty_members: Vec<Address> = Vec::new(&env);
env.storage()
.instance()
.set(&DataKey::Members, &empty_members);

env.events().publish(
(symbol_short!("init"),),
(admin, token, name, contribution_amount),
);
}

/// Add a member to the group (Admin only)
Expand All @@ -76,11 +83,19 @@ impl KoloSavingsContract {
if !members.contains(&new_member) {
members.push_back(new_member.clone());
env.storage().instance().set(&DataKey::Members, &members);
env.storage().persistent().set(&DataKey::Contributions(new_member.clone()), &0i128);
env.storage().persistent().set(&DataKey::HasReceivedPayout(new_member.clone()), &false);
env.storage().persistent().set(&DataKey::HasContributedThisCycle(new_member.clone()), &false);

env.events().publish((symbol_short!("add_mem"), new_member), ());
env.storage()
.persistent()
.set(&DataKey::Contributions(new_member.clone()), &0i128);
env.storage()
.persistent()
.set(&DataKey::HasReceivedPayout(new_member.clone()), &false);
env.storage().persistent().set(
&DataKey::HasContributedThisCycle(new_member.clone()),
&false,
);

env.events()
.publish((symbol_short!("add_mem"), new_member), ());
}
}

Expand All @@ -89,7 +104,11 @@ impl KoloSavingsContract {
member.require_auth();
extend_instance_ttl(&env);

let expected_amount: i128 = env.storage().instance().get(&DataKey::ContributionAmount).unwrap();
let expected_amount: i128 = env
.storage()
.instance()
.get(&DataKey::ContributionAmount)
.unwrap();
if amount != expected_amount {
panic!("Must contribute the exact amount");
}
Expand All @@ -102,10 +121,14 @@ impl KoloSavingsContract {
// Freeze the member count at the start of a cycle on the first contribution
if !env.storage().instance().has(&DataKey::CycleMemberCount) {
let count = members.len() as i128;
env.storage().instance().set(&DataKey::CycleMemberCount, &count);
env.storage()
.instance()
.set(&DataKey::CycleMemberCount, &count);
}

let has_contributed: bool = env.storage().persistent()
let has_contributed: bool = env
.storage()
.persistent()
.get(&DataKey::HasContributedThisCycle(member.clone()))
.unwrap_or(false);
if has_contributed {
Expand All @@ -118,15 +141,33 @@ impl KoloSavingsContract {
// Transfer tokens from the member to this contract
token_client.transfer(&member, &env.current_contract_address(), &amount);

env.storage().persistent().set(&DataKey::HasContributedThisCycle(member.clone()), &true);

let current_contribution: i128 = env.storage().persistent().get(&DataKey::Contributions(member.clone())).unwrap_or(0);
env.storage().persistent().set(&DataKey::Contributions(member.clone()), &(current_contribution + amount));

env.storage().persistent().extend_ttl(&DataKey::Contributions(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage().persistent().extend_ttl(&DataKey::HasContributedThisCycle(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);

env.events().publish((symbol_short!("contrib"), member), amount);
env.storage()
.persistent()
.set(&DataKey::HasContributedThisCycle(member.clone()), &true);

let current_contribution: i128 = env
.storage()
.persistent()
.get(&DataKey::Contributions(member.clone()))
.unwrap_or(0);
env.storage().persistent().set(
&DataKey::Contributions(member.clone()),
&(current_contribution + amount),
);

env.storage().persistent().extend_ttl(
&DataKey::Contributions(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
env.storage().persistent().extend_ttl(
&DataKey::HasContributedThisCycle(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);

env.events()
.publish((symbol_short!("contrib"), member), amount);
}

/// Withdraw payout (Admin triggers payout to a member)
Expand All @@ -141,30 +182,47 @@ impl KoloSavingsContract {
panic!("Recipient is not a member");
}

let has_received: bool = env.storage().persistent().get(&DataKey::HasReceivedPayout(recipient.clone())).unwrap_or(false);
let has_received: bool = env
.storage()
.persistent()
.get(&DataKey::HasReceivedPayout(recipient.clone()))
.unwrap_or(false);
if has_received {
panic!("Recipient has already received a payout this cycle");
}

let contribution_amount: i128 = env.storage().instance().get(&DataKey::ContributionAmount).unwrap();
let frozen_count: i128 = env.storage().instance()
let contribution_amount: i128 = env
.storage()
.instance()
.get(&DataKey::ContributionAmount)
.unwrap();
let frozen_count: i128 = env
.storage()
.instance()
.get(&DataKey::CycleMemberCount)
.expect("No active cycle");
let pool_size = contribution_amount * frozen_count;

let token: Address = env.storage().instance().get(&DataKey::Token).unwrap();
let token_client = token::Client::new(&env, &token);

let contract_balance = token_client.balance(&env.current_contract_address());
if pool_size > contract_balance {
panic!("Insufficient funds in contract for full payout");
}

env.storage().persistent().set(&DataKey::HasReceivedPayout(recipient.clone()), &true);
env.storage().persistent().extend_ttl(&DataKey::HasReceivedPayout(recipient.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage()
.persistent()
.set(&DataKey::HasReceivedPayout(recipient.clone()), &true);
env.storage().persistent().extend_ttl(
&DataKey::HasReceivedPayout(recipient.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
token_client.transfer(&env.current_contract_address(), &recipient, &pool_size);

env.events().publish((symbol_short!("payout"), recipient), pool_size);
env.events()
.publish((symbol_short!("payout"), recipient), pool_size);
}

/// Resets the payout cycle so members can receive payouts again.
Expand All @@ -175,10 +233,22 @@ impl KoloSavingsContract {

let members: Vec<Address> = env.storage().instance().get(&DataKey::Members).unwrap();
for member in members.iter() {
env.storage().persistent().set(&DataKey::HasReceivedPayout(member.clone()), &false);
env.storage().persistent().set(&DataKey::HasContributedThisCycle(member.clone()), &false);
env.storage().persistent().extend_ttl(&DataKey::HasReceivedPayout(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage().persistent().extend_ttl(&DataKey::HasContributedThisCycle(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage()
.persistent()
.set(&DataKey::HasReceivedPayout(member.clone()), &false);
env.storage()
.persistent()
.set(&DataKey::HasContributedThisCycle(member.clone()), &false);
env.storage().persistent().extend_ttl(
&DataKey::HasReceivedPayout(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
env.storage().persistent().extend_ttl(
&DataKey::HasContributedThisCycle(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
}

// Clear the frozen member count so it is re-established at the next cycle's first contribution
Expand All @@ -196,12 +266,26 @@ impl KoloSavingsContract {
}

pub fn get_contribution(env: Env, member: Address) -> i128 {
env.storage().persistent().extend_ttl(&DataKey::Contributions(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage().persistent().get(&DataKey::Contributions(member)).unwrap_or(0)
env.storage().persistent().extend_ttl(
&DataKey::Contributions(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
env.storage()
.persistent()
.get(&DataKey::Contributions(member))
.unwrap_or(0)
}

pub fn has_received_payout(env: Env, member: Address) -> bool {
env.storage().persistent().extend_ttl(&DataKey::HasReceivedPayout(member.clone()), LEDGERS_TO_LIVE / 2, LEDGERS_TO_LIVE);
env.storage().persistent().get(&DataKey::HasReceivedPayout(member)).unwrap_or(false)
env.storage().persistent().extend_ttl(
&DataKey::HasReceivedPayout(member.clone()),
LEDGERS_TO_LIVE / 2,
LEDGERS_TO_LIVE,
);
env.storage()
.persistent()
.get(&DataKey::HasReceivedPayout(member))
.unwrap_or(false)
}
}
27 changes: 20 additions & 7 deletions contracts/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#![cfg(test)]
#![allow(deprecated)]

use super::*;
use soroban_sdk::{testutils::Address as _, testutils::Events, Address, Env, String, symbol_short, vec, FromVal, IntoVal};
use soroban_sdk::token;
use soroban_sdk::{
symbol_short, testutils::Address as _, testutils::Events, vec, Address, Env, FromVal, IntoVal,
String,
};

#[test]
fn test_initialize() {
Expand Down Expand Up @@ -140,10 +144,10 @@ fn test_events() {

// 1. Test Initialize Event
client.initialize(&admin, &token, &name, &contribution_amount);

let events = env.events().all();
assert_eq!(events.len(), 1);

let init_event = events.get(0).unwrap();
assert_eq!(init_event.0, contract_id);
assert_eq!(
Expand All @@ -153,21 +157,30 @@ fn test_events() {
let init_data: (Address, Address, String, i128) = <_>::from_val(&env, &init_event.2);
assert_eq!(
init_data,
(admin.clone(), token.clone(), name.clone(), contribution_amount)
(
admin.clone(),
token.clone(),
name.clone(),
contribution_amount
)
);

// 2. Test Add Member Event
let member1 = Address::generate(&env);
client.add_member(&member1);

let events = env.events().all();
assert_eq!(events.len(), 2); // 2 events now

let add_mem_event = events.get(1).unwrap();
assert_eq!(add_mem_event.0, contract_id);
assert_eq!(
add_mem_event.1,
vec![&env, symbol_short!("add_mem").into_val(&env), member1.clone().into_val(&env)]
vec![
&env,
symbol_short!("add_mem").into_val(&env),
member1.clone().into_val(&env)
]
);
let add_mem_data: () = <_>::from_val(&env, &add_mem_event.2);
assert_eq!(add_mem_data, ());
Expand Down
Loading