diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d6eb1f..9976281 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/contracts/dividend/src/lib.rs b/contracts/dividend/src/lib.rs index 10b3a9e..3cc6089 100644 --- a/contracts/dividend/src/lib.rs +++ b/contracts/dividend/src/lib.rs @@ -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
, + /// Payout actually transferred to each recipient (rounded down). pub amounts: Vec, + /// 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, } @@ -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); @@ -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 { 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"); } diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs index 7d9476b..8772923 100644 --- a/contracts/governance/src/lib.rs +++ b/contracts/governance/src/lib.rs @@ -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, @@ -55,8 +95,20 @@ 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); @@ -64,12 +116,20 @@ impl GovernanceContract { 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"); } } -} +} \ No newline at end of file diff --git a/contracts/loan/src/lib.rs b/contracts/loan/src/lib.rs index e08ceae..daefdc7 100644 --- a/contracts/loan/src/lib.rs +++ b/contracts/loan/src/lib.rs @@ -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, } @@ -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) { @@ -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, id: u32) -> u32 { for i in 0..loans.len() { if loans.get(i).unwrap().id == id { diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index 786f8f7..66fb317 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -1,5 +1,30 @@ #![no_std] +//! # TreasuryContract +//! +//! Soroban smart contract that implements the group wallet for a cooperative: +//! member registration, periodic contribution collection, and admin-gated +//! withdrawals. +//! +//! ## Storage layout +//! All state lives in `env.storage().instance()` (group metadata, members +//! list, totals) and `env.storage().persistent()` (per-member contribution +//! history). Keys are described in [`DataKey`]. +//! +//! ## Authorization model +//! * `initialize` — admin only; re-initialization is rejected. +//! * `add_member`, `withdraw` — admin only. +//! * `contribute` — member only; signer must be in the members list. +//! * Read-only views — open to anyone. +//! +//! ## Events +//! * `member_added(member)` +//! * `contribution(member, amount, period)` +//! * `withdrawal(to, amount)` +//! +//! ## Panics +//! All `panic!` conditions are documented on the individual functions. + use soroban_sdk::{ contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec, String, }; @@ -62,6 +87,25 @@ pub struct TreasuryContract; #[contractimpl] impl TreasuryContract { /// Initialize a new cooperative treasury group. + /// + /// # Authorization + /// Requires `admin.require_auth()`. + /// + /// # Arguments + /// * `admin` - The address authorized to add members and withdraw funds. + /// * `group_name` - Human-readable group name (stored verbatim). + /// * `asset` - The SAC token contract address used for contributions and + /// withdrawals. + /// + /// # Returns + /// A fresh [`GroupInfo`] snapshot reflecting the just-initialized state + /// (zero members, zero contributions, `is_active = true`). + /// + /// # Events + /// None. + /// + /// # Panics + /// If the contract has already been initialized. pub fn initialize( env: Env, admin: Address, @@ -92,6 +136,21 @@ impl TreasuryContract { } /// Add a new member to the cooperative. + /// + /// # Authorization + /// Requires `admin.require_auth()` and `admin` must equal the stored + /// admin address. + /// + /// # Arguments + /// * `admin` - The admin performing the addition. + /// * `member` - Address to add. If already a member, the call is a + /// no-op (idempotent). + /// + /// # Events + /// Emits `member_added(member)` only if `member` was not already present. + /// + /// # Panics + /// If `admin` is not the stored admin. pub fn add_member(env: Env, admin: Address, member: Address) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -112,6 +171,24 @@ impl TreasuryContract { } /// Record a member contribution. Transfers USDC from member to this contract. + /// + /// # Authorization + /// Requires `member.require_auth()`, and `member` must be in the stored + /// members list. + /// + /// # Arguments + /// * `member` - The contributing member; must sign the call. + /// * `amount` - Token amount to contribute. Must be strictly positive. + /// * `period` - Cooperative-defined period number (e.g. month index) + /// stored alongside the contribution record. + /// + /// # Events + /// Emits `contribution(member, amount, period)` after the token transfer + /// succeeds and the record has been stored. + /// + /// # Panics + /// * If `amount <= 0`. + /// * If `member` is not in the members list. pub fn contribute(env: Env, member: Address, amount: i128, period: u32) { member.require_auth(); Self::require_member(&env, &member); @@ -155,6 +232,24 @@ impl TreasuryContract { } /// Withdraw funds — only callable by admin (e.g. for approved loans or expenses). + /// + /// # Authorization + /// Requires `admin.require_auth()` and `admin` must equal the stored + /// admin address. + /// + /// # Arguments + /// * `admin` - The admin performing the withdrawal. + /// * `to` - Recipient address. May be a member, a loan contract, or any + /// arbitrary address authorized by the cooperative. + /// * `amount` - Token amount to transfer. The token client will reject + /// the transfer if the contract's balance is insufficient. + /// + /// # Events + /// Emits `withdrawal(to, amount)` after the token transfer succeeds. + /// + /// # Panics + /// * If `admin` is not the stored admin. + /// * If the contract does not hold at least `amount` of the asset. pub fn withdraw(env: Env, admin: Address, to: Address, amount: i128) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -170,6 +265,18 @@ impl TreasuryContract { } /// Get current treasury balance. + /// + /// # Authorization + /// None — read-only view. + /// + /// # Returns + /// The contract's current token balance in the asset's base units. + /// + /// # Events + /// None. + /// + /// # Panics + /// If the contract has not been initialized (no asset address stored). pub fn balance(env: Env) -> i128 { let asset: Address = env.storage().instance().get(&DataKey::AssetAddress).unwrap(); let token_client = token::Client::new(&env, &asset); @@ -177,6 +284,19 @@ impl TreasuryContract { } /// Get all members. + /// + /// # Authorization + /// None — read-only view. + /// + /// # Returns + /// `Vec
` of all members in insertion order. Returns an empty + /// vector if no member has been added yet. + /// + /// # Events + /// None. + /// + /// # Panics + /// Never panics. pub fn get_members(env: Env) -> Vec
{ env.storage().instance() .get(&DataKey::Members) @@ -184,6 +304,22 @@ impl TreasuryContract { } /// Get contribution history for a member. + /// + /// # Authorization + /// None — read-only view. + /// + /// # Arguments + /// * `member` - The member whose history to fetch. + /// + /// # Returns + /// `Vec` in insertion order. Returns an empty vector + /// for unknown members or members who have not yet contributed. + /// + /// # Events + /// None. + /// + /// # Panics + /// Never panics. pub fn get_contributions(env: Env, member: Address) -> Vec { env.storage().persistent() .get(&DataKey::Contributions(member)) @@ -191,6 +327,18 @@ impl TreasuryContract { } /// Get full group info. + /// + /// # Authorization + /// None — read-only view. + /// + /// # Returns + /// A fresh [`GroupInfo`] snapshot assembled from current storage. + /// + /// # Events + /// None. + /// + /// # Panics + /// If the contract has not been initialized. pub fn get_info(env: Env) -> GroupInfo { let members: Vec
= env.storage().instance() .get(&DataKey::Members) @@ -254,6 +402,12 @@ impl TreasuryContract { // ── Internal helpers ───────────────────────────────────────────────────── + /// Verify that `caller` matches the stored admin address. + /// + /// Internal helper. Not part of the contract's public ABI. + /// + /// # Panics + /// If `caller` does not match the stored admin (or no admin is set). fn require_admin(env: &Env, caller: &Address) { let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); if admin != *caller { @@ -261,6 +415,12 @@ impl TreasuryContract { } } + /// Verify that `caller` is in the stored members list. + /// + /// Internal helper. Not part of the contract's public ABI. + /// + /// # Panics + /// If `caller` is not in the members list (or no members have been added). fn require_member(env: &Env, caller: &Address) { let members: Vec
= env.storage().instance() .get(&DataKey::Members) diff --git a/contracts/voting/src/lib.rs b/contracts/voting/src/lib.rs index f9a22f6..1217837 100644 --- a/contracts/voting/src/lib.rs +++ b/contracts/voting/src/lib.rs @@ -1,54 +1,97 @@ #![no_std] +//! On-chain governance: proposal + simple yes/no voting for a cooperative. +//! +//! [`VotingContract`] owns the lifecycle of every [`Proposal`]: members +//! call [`create_proposal`] to open one, then [`vote`] to cast their +//! ballot, and finally [`finalize`] to close the proposal once the +//! deadline has passed. +//! +//! Pass / fail criteria are intentionally simple: a proposal passes iff +//! `votes_for > votes_against` AND total votes reach the configured quorum. +//! There is no delegate voting and no vote replacement; one address, one +//! vote per proposal. + use soroban_sdk::{ contract, contractimpl, contracttype, Address, Env, Map, Symbol, Vec, String, }; +/// Storage keys for [`VotingContract`]. #[contracttype] #[derive(Clone)] pub enum DataKey { + /// Admin address (informational; not enforced in current code). Admin, + /// Address of the sibling treasury contract. TreasuryContract, + /// Append-only log of every [`Proposal`] created on this contract. Proposals, + /// Monotonically increasing counter used to assign proposal IDs. ProposalCounter, - Votes(u32), // proposal_id -> Map + /// Persistent storage: per-proposal map of `voter -> approve`. + Votes(u32), } +/// Lifecycle status of a [`Proposal`]. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum ProposalStatus { + /// Open for voting; [`vote`] is allowed. Active, + /// Deadline passed and quorum + majority reached. Passed, + /// Deadline passed and quorum / majority not reached. Failed, + /// Passed proposal whose payload has been executed (terminal state). Executed, } +/// Coarse categorization of a proposal; useful for off-chain UIs. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum ProposalType { - LoanApproval, // Approve a member loan - TreasurySpend, // Authorize a treasury withdrawal - AddMember, // Add a new member to the coop - RemoveMember, // Remove a member from the coop - UpdateRule, // Change a group rule (interest rate, contrib amount, etc.) - General, // General governance proposal + /// Approve a member loan. + LoanApproval, + /// Authorize a treasury withdrawal. + TreasurySpend, + /// Add a new member to the coop. + AddMember, + /// Remove a member from the coop. + RemoveMember, + /// Change a group rule (interest rate, contribution amount, etc.). + UpdateRule, + /// General governance proposal. + General, } +/// Single proposal record. #[contracttype] #[derive(Clone, Debug)] pub struct Proposal { + /// Auto-incremented ID assigned at create time. pub id: u32, + /// Address that opened the proposal. pub proposer: Address, + /// Coarse proposal category. pub proposal_type: ProposalType, + /// Short title (max 64 chars enforced off-chain). pub title: String, + /// Long-form description (markdown encouraged off-chain). pub description: String, + /// Running total of yes votes. pub votes_for: u32, + /// Running total of no votes. pub votes_against: u32, - pub quorum: u32, // Minimum votes required - pub deadline: u64, // Ledger timestamp + /// Minimum total votes required for the proposal to pass. + pub quorum: u32, + /// Ledger timestamp after which voting is closed. + pub deadline: u64, + /// Current status; see [`ProposalStatus`]. pub status: ProposalStatus, + /// Ledger timestamp at creation. pub created_at: u64, - pub payload: String, // JSON-encoded action payload + /// JSON-encoded action payload consumed by an executor contract. + pub payload: String, } #[contract] @@ -56,6 +99,13 @@ pub struct VotingContract; #[contractimpl] impl VotingContract { + /// Initialize the voting contract. + /// + /// Stores the admin, treasury contract address, an empty proposal + /// counter, and an empty proposals log. Must be called exactly once. + /// + /// # Authorization + /// Requires auth from `admin`. pub fn initialize(env: Env, admin: Address, treasury: Address) { admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); @@ -192,18 +242,29 @@ impl VotingContract { status } + /// Return every proposal ever created on this contract, oldest first. +/// +/// Returns an empty vector if no proposals have been created yet. pub fn get_proposals(env: Env) -> Vec { env.storage().instance() .get(&DataKey::Proposals) .unwrap_or(Vec::new(&env)) } + /// Return the full `voter -> approve` map for `proposal_id`. + /// + /// Returns an empty map if no votes have been cast. pub fn get_votes(env: Env, proposal_id: u32) -> Map { env.storage().persistent() .get(&DataKey::Votes(proposal_id)) .unwrap_or(Map::new(&env)) } + /// Internal: linear search for a proposal by ID in the on-chain log. + /// + /// # Panics + /// Panics with `"proposal not found"` when no proposal has the + /// supplied ID. fn find_proposal_idx(proposals: &Vec, id: u32) -> u32 { for i in 0..proposals.len() { if proposals.get(i).unwrap().id == id { return i; }