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
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ jobs:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace -- -D warnings

docs:
name: Build Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- run: cargo doc --workspace --no-deps --document-private-items
96 changes: 84 additions & 12 deletions contracts/dividend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
//! Dividend distribution contract for Soroban-based cooperatives.
//!
//! Distributes profits to cooperative members proportionally based on
//! their share weights. Admins supply a list of recipients and their
//! respective share values, along with the total profit to distribute.
//!
//! The contract is `no_std` and Soroban-targeted.
//!
//! # Events
//!
//! - `dividend_distributed` — emitted when a distribution is executed.

#![no_std]

use soroban_sdk::{
contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec,
};
use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec};

/// Storage keys for the dividend contract.
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
/// The admin address (set at initialization).
Admin,
/// The token asset used for distributions.
AssetAddress,
/// Address of the treasury contract that holds funds for distribution.
TreasuryContract,
/// Persistent vector of all [`Distribution`] records.
Distributions,
/// Monotonically increasing distribution counter.
DistributionCounter,
}

/// A single profit distribution record.
#[contracttype]
#[derive(Clone, Debug)]
pub struct Distribution {
/// Unique distribution identifier.
pub id: u32,
/// Total profit distributed in this round.
pub total_profit: i128,
/// Sum of all share weights used for this distribution.
pub total_shares: i128,
/// List of recipient addresses.
pub recipients: Vec<Address>,
/// Payout amounts corresponding to each recipient.
pub amounts: Vec<i128>,
/// Ledger timestamp when the distribution was executed.
pub executed_at: u64,
/// Human-readable period label (e.g. "Q3-2026").
pub period: String,
}

Expand All @@ -31,20 +55,48 @@ pub struct DividendContract;

#[contractimpl]
impl DividendContract {
/// Initialize the dividend contract with admin, asset, and treasury.
///
/// # Authorization
///
/// The `admin` must authenticate (via `require_auth`).
///
/// # Events
///
/// Emits no events directly; initialization is a one-time setup step.
pub fn initialize(env: Env, admin: Address, asset: Address, treasury: Address) {
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::AssetAddress, &asset);
env.storage().instance().set(&DataKey::TreasuryContract, &treasury);
env.storage().instance().set(&DataKey::DistributionCounter, &0u32);
env.storage().instance()
env.storage()
.instance()
.set(&DataKey::Distributions, &Vec::<Distribution>::new(&env));
}

/// Distribute profit proportionally based on each member's share weight.
///
/// `recipients` and `shares` must be equal length.
/// Each member receives: `profit * (member_shares / total_shares)`
///
/// # Authorization
///
/// The `admin` must authenticate and be the current admin of the contract.
///
/// # Panics
///
/// - Panics if `recipients` and `shares` have different lengths.
/// - Panics if `total_profit` is zero or negative.
/// - Panics if `total_shares` is zero.
///
/// # Events
///
/// Emits `dividend_distributed` with `(id, total_profit, recipient_count)`.
///
/// # Returns
///
/// The distribution record ID.
pub fn distribute(
env: Env,
admin: Address,
Expand All @@ -59,10 +111,14 @@ impl DividendContract {
if recipients.len() != shares.len() {
panic!("recipients and shares length mismatch");
}
if total_profit <= 0 { panic!("profit must be positive"); }
if total_profit <= 0 {
panic!("profit must be positive");
}

let total_shares: i128 = shares.iter().sum();
if total_shares == 0 { panic!("total shares cannot be zero"); }
if total_shares == 0 {
panic!("total shares cannot be zero");
}

let asset: Address = env.storage().instance().get(&DataKey::AssetAddress).unwrap();
let token_client = token::Client::new(&env, &asset);
Expand All @@ -82,8 +138,11 @@ impl DividendContract {
amounts.push_back(payout);
}

let counter: u32 = env.storage().instance()
.get(&DataKey::DistributionCounter).unwrap_or(0);
let counter: u32 = env
.storage()
.instance()
.get(&DataKey::DistributionCounter)
.unwrap_or(0);
let id = counter + 1;

let dist = Distribution {
Expand All @@ -96,8 +155,11 @@ impl DividendContract {
period,
};

let mut distributions: Vec<Distribution> = env.storage().instance()
.get(&DataKey::Distributions).unwrap_or(Vec::new(&env));
let mut distributions: Vec<Distribution> = env
.storage()
.instance()
.get(&DataKey::Distributions)
.unwrap_or(Vec::new(&env));
distributions.push_back(dist);
env.storage().instance().set(&DataKey::Distributions, &distributions);
env.storage().instance().set(&DataKey::DistributionCounter, &id);
Expand All @@ -109,14 +171,24 @@ impl DividendContract {
id
}

/// Get all distribution records.
///
/// Read-only — no auth required.
///
/// # Returns
///
/// A vector of all [`Distribution`] records, empty if none exist.
pub fn get_distributions(env: Env) -> Vec<Distribution> {
env.storage().instance()
env.storage()
.instance()
.get(&DataKey::Distributions)
.unwrap_or(Vec::new(&env))
}

fn require_admin(env: &Env, caller: &Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if admin != *caller { panic!("unauthorized"); }
if admin != *caller {
panic!("unauthorized");
}
}
}
73 changes: 65 additions & 8 deletions contracts/governance/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
//! Governance contract for Soroban-based cooperative rule management.
//!
//! Stores and updates the cooperative's configurable rules (contribution
//! amounts, loan multipliers, voting quorum, etc.). Acts as the central
//! policy engine that the treasury, loan, and voting contracts defer to.
//!
//! The contract is `no_std` and Soroban-targeted.
//!
//! # Events
//!
//! - `rules_updated` — emitted when cooperative rules are changed.

#![no_std]

use soroban_sdk::{
contract, contractimpl, contracttype, Address, Env, Symbol, Vec,
};
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec};

/// Storage keys for the governance contract.
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
/// The admin address (set at initialization).
Admin,
/// Address of the voting contract.
VotingContract,
/// Address of the loan contract.
LoanContract,
/// Address of the treasury contract.
TreasuryContract,
/// The cooperative's configurable rules.
Rules,
}

/// Cooperative rules governing contributions, loans, and voting.
#[contracttype]
#[derive(Clone, Debug)]
pub struct CoopRules {
/// Minimum contribution amount per period, in the asset's minor units.
pub min_contribution: i128,
/// Length of a contribution period in days.
pub contribution_period_days: u32,
pub max_loan_multiplier: u32, // e.g. 3 = max loan is 3x your total contributions
/// Maximum loan multiplier (e.g. 3 = loan up to 3× total contributions).
pub max_loan_multiplier: u32,
/// Flat interest rate on loans, in basis points (e.g. 500 = 5%).
pub loan_interest_bps: u32,
/// Minimum number of votes required for a proposal to pass.
pub voting_quorum: u32,
/// Length of a voting period in days.
pub voting_period_days: u32,
/// Late payment penalty, in basis points (e.g. 200 = 2%).
pub late_penalty_bps: u32,
}

Expand All @@ -31,6 +55,17 @@ pub struct GovernanceContract;

#[contractimpl]
impl GovernanceContract {
/// Initialize the governance contract with admin and dependent contract addresses.
///
/// Sets default cooperative rules tuned for an African ROSCA/SACCO.
///
/// # Authorization
///
/// The `admin` must authenticate (via `require_auth`).
///
/// # Events
///
/// Emits no events directly; initialization is a one-time setup step.
pub fn initialize(
env: Env,
admin: Address,
Expand All @@ -46,30 +81,52 @@ impl GovernanceContract {

// Sensible defaults for an African ROSCA/SACCO
let rules = CoopRules {
min_contribution: 10_0000000i128, // 10 USDC
min_contribution: 10_0000000i128, // 10 USDC
contribution_period_days: 30,
max_loan_multiplier: 3,
loan_interest_bps: 500, // 5%
loan_interest_bps: 500, // 5%
voting_quorum: 3,
voting_period_days: 7,
late_penalty_bps: 200, // 2% penalty
late_penalty_bps: 200, // 2% penalty
};
env.storage().instance().set(&DataKey::Rules, &rules);
}

/// Update the cooperative's configurable rules.
///
/// # Authorization
///
/// The `admin` must authenticate and be the current admin of the contract.
///
/// # Panics
///
/// Panics if `admin` is not the contract's admin.
///
/// # Events
///
/// Emits `rules_updated`.
pub fn update_rules(env: Env, admin: Address, rules: CoopRules) {
admin.require_auth();
Self::require_admin(&env, &admin);
env.storage().instance().set(&DataKey::Rules, &rules);
env.events().publish((Symbol::new(&env, "rules_updated"),), ());
}

/// Get the current cooperative rules.
///
/// Read-only — no auth required.
///
/// # Returns
///
/// The current [`CoopRules`].
pub fn get_rules(env: Env) -> CoopRules {
env.storage().instance().get(&DataKey::Rules).unwrap()
}

fn require_admin(env: &Env, caller: &Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if admin != *caller { panic!("unauthorized"); }
if admin != *caller {
panic!("unauthorized");
}
}
}
Loading