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

docs:
name: Build rustdoc (warnings as errors)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Cache Rust
uses: Swatinem/rust-cache@v2

- name: Build documentation
# -D warnings promotes any doc warning to a CI failure so that
# broken intra-doc links or missing docs are caught early.
# --no-deps skips generating docs for Soroban SDK + transitive deps.
run: cargo doc --workspace --no-deps -- -D warnings

- name: Upload docs artifact
uses: actions/upload-artifact@v4
with:
name: rustdoc-${{ github.sha }}
path: target/doc
if-no-files-found: warn
retention-days: 14
39 changes: 39 additions & 0 deletions contracts/dividend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,53 @@
#![no_std]

//! Profit distribution contract for a cooperative.
//!
//! [`DividendContract`] accepts an admin-defined profit pool and a list of
//! member share weights, then transfers token payouts proportionally from
//! the contract's own balance to each recipient. Every payout is recorded
//! on-chain as a [`Distribution`] so that the treasury can later audit
//! who received what and when.
//!
//! The contract assumes the asset address is a SAC22 token whose client
//! implements the standard [`token::Client`] `transfer` interface.

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

/// Storage keys for [`DividendContract`].
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
/// Admin address authorized to call [`DividendContract::distribute`].
Admin,
/// Address of the SAC22 token used for payouts.
AssetAddress,
/// Address of the sibling treasury contract (informational; not enforced).
TreasuryContract,
/// Append-only log of every [`Distribution`] executed by this contract.
Distributions,
/// Monotonically increasing counter used to assign distribution IDs.
DistributionCounter,
}

/// Single on-chain record of one profit-distribution event.
#[contracttype]
#[derive(Clone, Debug)]
pub struct Distribution {
/// Auto-incremented ID assigned at execution time.
pub id: u32,
/// Total profit (in token base units) that was available to distribute.
pub total_profit: i128,
/// Sum of all `shares` in the call that produced this distribution.
pub total_shares: i128,
/// Recipients in the same order as the call's `recipients` argument.
pub recipients: Vec<Address>,
/// Payout actually transferred to each recipient (rounded down).
pub amounts: Vec<i128>,
/// Ledger timestamp at which the distribution was executed.
pub executed_at: u64,
/// Human-readable period label (e.g. `"Q3-2026"`) supplied by the admin.
pub period: String,
}

Expand All @@ -31,6 +56,13 @@ pub struct DividendContract;

#[contractimpl]
impl DividendContract {
/// Initialize the dividend contract.
///
/// Stores the admin, asset address, treasury contract address, and an
/// empty distribution counter + log. Must be called exactly once.
///
/// # Authorization
/// Requires auth from `admin`.
pub fn initialize(env: Env, admin: Address, asset: Address, treasury: Address) {
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
Expand Down Expand Up @@ -109,12 +141,19 @@ impl DividendContract {
id
}

/// Return the full on-chain history of [`Distribution`] events, oldest first.
///
/// Returns an empty vector if no distributions have been executed yet.
pub fn get_distributions(env: Env) -> Vec<Distribution> {
env.storage().instance()
.get(&DataKey::Distributions)
.unwrap_or(Vec::new(&env))
}

/// Internal: assert that `caller` is the admin registered at [`initialize`].
///
/// # Panics
/// Panics with `"unauthorized"` when `caller` does not match.
fn require_admin(env: &Env, caller: &Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if admin != *caller { panic!("unauthorized"); }
Expand Down
64 changes: 62 additions & 2 deletions contracts/governance/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,76 @@
#![no_std]

//! Governance contract for cooperative rules and contract registry.
//!
//! Stores the [`CoopRules`] used by every other contract in the workspace
//! (loan interest, voting quorum, contribution period) plus the addresses of
//! the sibling contracts (`voting`, `loan`, `treasury`) that the coop needs
//! to talk to. Only the admin address registered at [`initialize`] can call
//! [`update_rules`].
//!
//! Defaults seeded in [`initialize`] are tuned for an African ROSCA / SACCO
//! (10 USDC minimum monthly contribution, 5% loan interest, 3-vote quorum).

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

/// Storage keys used by [`GovernanceContract`].
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
/// Admin address authorized to mutate rules.
Admin,
/// Address of the [`VotingContract`] (set at initialize).
VotingContract,
/// Address of the [`LoanContract`] (set at initialize).
LoanContract,
/// Address of the [`TreasuryContract`] (set at initialize).
TreasuryContract,
/// Current [`CoopRules`] stored as an instance value.
Rules,
}

/// Cooperative governance parameters shared across the workspace.
///
/// All numeric fields are intentionally simple integer types so they can be
/// persisted directly in Soroban instance storage and consumed by the loan,
/// voting, and treasury contracts.
#[contracttype]
#[derive(Clone, Debug)]
pub struct CoopRules {
/// Minimum monthly contribution in token base units (7 decimals on USDC).
pub min_contribution: i128,
/// Length of a contribution cycle in days.
pub contribution_period_days: u32,
pub max_loan_multiplier: u32, // e.g. 3 = max loan is 3x your total contributions
/// Maximum loan size as a multiple of total contributions
/// (e.g. `3` means a member can borrow up to 3x their contributed total).
pub max_loan_multiplier: u32,
/// Annualized loan interest rate in basis points (500 = 5.00%).
pub loan_interest_bps: u32,
/// Minimum number of yes votes required for a proposal to pass.
pub voting_quorum: u32,
/// Number of days a proposal stays open for voting.
pub voting_period_days: u32,
/// Penalty applied to late contributions, in basis points (200 = 2.00%).
pub late_penalty_bps: u32,
}

/// Contract entry point. Single global instance per deployment.
#[contract]
pub struct GovernanceContract;

#[contractimpl]
impl GovernanceContract {
/// Initialize the governance contract with admin and sibling-contract
/// addresses, then seed sensible default [`CoopRules`].
///
/// # Authorization
/// Requires auth from `admin`; the same address becomes the only account
/// that can mutate rules via [`update_rules`].
///
/// # Events
/// * topic `"governance_initialized"` — payload `(admin, voting, loan, treasury)`
pub fn initialize(
env: Env,
admin: Address,
Expand All @@ -55,21 +95,41 @@ impl GovernanceContract {
late_penalty_bps: 200, // 2% penalty
};
env.storage().instance().set(&DataKey::Rules, &rules);

env.events().publish(
(Symbol::new(&env, "governance_initialized"),),
(admin, voting, loan, treasury),
);
}

/// Replace the current [`CoopRules`] with the provided `rules`.
///
/// # Panics
/// Panics if `admin` is not the registered admin.
///
/// # Events
/// * topic `"rules_updated"` — payload `()`
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"),), ());
}

/// Read the current [`CoopRules`].
///
/// # Panics
/// Panics if [`initialize`] has not yet been called.
pub fn get_rules(env: Env) -> CoopRules {
env.storage().instance().get(&DataKey::Rules).unwrap()
}

/// Internal: assert that `caller` is the admin registered at [`initialize`].
///
/// # Panics
/// Panics with `"unauthorized"` when `caller` does not match.
fn require_admin(env: &Env, caller: &Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if admin != *caller { panic!("unauthorized"); }
}
}
}
67 changes: 60 additions & 7 deletions contracts/loan/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,41 +1,75 @@
#![no_std]

//! Member-loan contract for a cooperative.
//!
//! [`LoanContract`] is the lifecycle owner of every loan in the coop:
//! a member calls [`request_loan`] to file an application, governance
//! (or the admin) calls [`approve_loan`] to disburse funds from the
//! treasury, and the borrower repays in one or more calls to [`repay`].
//!
//! All amounts are denominated in token base units (7 decimals on USDC),
//! and interest is computed in basis points against the principal only.

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

/// Storage keys for [`LoanContract`].
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
/// Admin address authorized to approve loans.
Admin,
/// Address of the sibling treasury contract.
TreasuryContract,
/// Address of the SAC22 token used for disbursement and repayment.
AssetAddress,
/// Append-only log of every [`Loan`] created on this contract.
Loans,
/// Monotonically increasing counter used to assign loan IDs.
LoanCounter,
}

/// Status of a loan through its lifecycle.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum LoanStatus {
Pending, // Awaiting approval vote
Approved, // Disbursed
Repaid, // Fully repaid
Rejected, // Rejected by governance
Defaulted, // Past due date, not repaid
/// Awaiting approval vote.
Pending,
/// Disbursed to the borrower.
Approved,
/// Fully repaid (principal + interest).
Repaid,
/// Rejected by governance.
Rejected,
/// Past due date, not repaid.
Defaulted,
}

/// Single loan record. Fields are set incrementally as the loan moves
/// through the [`LoanStatus`] lifecycle.
#[contracttype]
#[derive(Clone, Debug)]
pub struct Loan {
/// Auto-incremented ID assigned at request time.
pub id: u32,
/// Address of the borrower; principal is disbursed to this account.
pub borrower: Address,
/// Principal amount in token base units.
pub amount: i128,
pub interest_bps: u32, // basis points, e.g. 500 = 5%
pub repayment_due: u64, // ledger timestamp deadline
/// Interest rate in basis points (500 = 5.00%).
pub interest_bps: u32,
/// Ledger timestamp at which repayment is due.
pub repayment_due: u64,
/// Running total of all repayments applied to this loan.
pub amount_repaid: i128,
/// Current status; see [`LoanStatus`].
pub status: LoanStatus,
/// Human-readable purpose supplied by the borrower.
pub purpose: String,
/// Ledger timestamp when the loan was requested.
pub requested_at: u64,
/// Ledger timestamp when the loan was approved (0 until then).
pub approved_at: u64,
}

Expand All @@ -44,6 +78,17 @@ pub struct LoanContract;

#[contractimpl]
impl LoanContract {
/// Initialize the loan contract.
///
/// Stores the admin, treasury contract address, asset address, and an
/// empty loan counter + log. Must be called exactly once.
///
/// # Panics
/// Panics with `"already initialized"` if the contract already has an
/// admin on file.
///
/// # Authorization
/// Requires auth from `admin`.
pub fn initialize(env: Env, admin: Address, treasury: Address, asset: Address) {
admin.require_auth();
if env.storage().instance().has(&DataKey::Admin) {
Expand Down Expand Up @@ -187,11 +232,19 @@ impl LoanContract {
loans.get(idx).unwrap()
}

/// Internal: assert that `caller` is the admin registered at [`initialize`].
///
/// # Panics
/// Panics with `"unauthorized"` when `caller` does not match.
fn require_admin(env: &Env, caller: &Address) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if admin != *caller { panic!("unauthorized"); }
}

/// Internal: linear search for a loan by ID in the on-chain log.
///
/// # Panics
/// Panics with `"loan not found"` when no loan has the supplied ID.
fn find_loan_idx(loans: &Vec<Loan>, id: u32) -> u32 {
for i in 0..loans.len() {
if loans.get(i).unwrap().id == id {
Expand Down
Loading