diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5bb803a --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/README.md b/README.md index f6c0750..356a4f1 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 718c19d..e9433bd 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -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] diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 0267803..17082f4 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -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
= 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 = 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) @@ -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), ()); } } @@ -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"); } @@ -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 { @@ -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) @@ -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. @@ -175,10 +233,22 @@ impl KoloSavingsContract { let members: Vec = 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 @@ -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) } } diff --git a/contracts/src/test.rs b/contracts/src/test.rs index 5b281c6..fb23fad 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -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() { @@ -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!( @@ -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, ());