diff --git a/contracts/split/src/cooperative.rs b/contracts/split/src/cooperative.rs new file mode 100644 index 0000000..6fff4d9 --- /dev/null +++ b/contracts/split/src/cooperative.rs @@ -0,0 +1,237 @@ +//! Issue #890: Creator cooperative profit sharing. +//! +//! Creators form a cooperative with fixed member shares (basis points summing +//! to 10_000). Anyone may deposit profit into a cooperative; each deposit is +//! immediately credited to members pro rata and members claim at will. + +use crate::SplitContract; +#[allow(unused_imports)] +use crate::{SplitContractArgs, SplitContractClient}; +use soroban_sdk::{contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; + +/// Maximum members per cooperative (bounds per-deposit work). +const MAX_COOP_MEMBERS: u32 = 50; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum CoopKey { + NextId, + Coop(u64), + /// Claimable balance per (coop, token, member): i128 + Claimable(u64, Address, Address), + /// Total ever deposited per (coop, token): i128 + Deposited(u64, Address), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Cooperative { + pub id: u64, + pub founder: Address, + pub members: Vec
, + pub shares_bps: Vec, +} + +fn load_coop(env: &Env, coop_id: u64) -> Cooperative { + env.storage() + .persistent() + .get(&CoopKey::Coop(coop_id)) + .expect("cooperative not found") +} + +#[contractimpl] +impl SplitContract { + /// Create a cooperative. `shares_bps` must align with `members`, contain + /// no zero entries, and sum to exactly 10_000. Returns the cooperative id. + pub fn create_cooperative( + env: Env, + founder: Address, + members: Vec
, + shares_bps: Vec, + ) -> u64 { + founder.require_auth(); + assert!( + !members.is_empty() && members.len() <= MAX_COOP_MEMBERS, + "invalid member count" + ); + assert!(members.len() == shares_bps.len(), "members/shares length mismatch"); + let mut total: u32 = 0; + for (i, share) in shares_bps.iter().enumerate() { + assert!(share > 0, "share must be positive"); + total += share; + let m = members.get(i as u32).unwrap(); + for j in 0..i as u32 { + assert!(members.get(j).unwrap() != m, "duplicate member"); + } + } + assert!(total == 10_000, "shares must sum to 10000 bps"); + + let id: u64 = env.storage().persistent().get(&CoopKey::NextId).unwrap_or(1u64); + env.storage().persistent().set(&CoopKey::NextId, &(id + 1)); + let coop = Cooperative { + id, + founder: founder.clone(), + members, + shares_bps, + }; + env.storage().persistent().set(&CoopKey::Coop(id), &coop); + env.events().publish( + (symbol_short!("split"), symbol_short!("coop_new"), id), + (founder, coop.members.len()), + ); + id + } + + /// Deposit profit into a cooperative; it is credited to members by share. + /// Rounding dust goes to the last member so credits always sum to `amount`. + pub fn deposit_coop_profit( + env: Env, + from: Address, + coop_id: u64, + token: Address, + amount: i128, + ) { + from.require_auth(); + assert!(amount > 0, "amount must be positive"); + let coop = load_coop(&env, coop_id); + token::Client::new(&env, &token).transfer( + &from, + &env.current_contract_address(), + &amount, + ); + let last = coop.members.len() - 1; + let mut credited: i128 = 0; + for (i, member) in coop.members.iter().enumerate() { + let i = i as u32; + let part = if i == last { + amount - credited + } else { + amount * coop.shares_bps.get(i).unwrap() as i128 / 10_000 + }; + credited += part; + let key = CoopKey::Claimable(coop_id, token.clone(), member); + let cur: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(cur + part)); + } + let dkey = CoopKey::Deposited(coop_id, token.clone()); + let dep: i128 = env.storage().persistent().get(&dkey).unwrap_or(0); + env.storage().persistent().set(&dkey, &(dep + amount)); + env.events().publish( + (symbol_short!("split"), symbol_short!("coop_dep"), coop_id), + (from, token, amount), + ); + } + + /// Claim a member's accumulated profit in `token`. Returns the amount paid. + pub fn claim_coop_profit(env: Env, member: Address, coop_id: u64, token: Address) -> i128 { + member.require_auth(); + let coop = load_coop(&env, coop_id); + assert!(coop.members.contains(member.clone()), "not a cooperative member"); + let key = CoopKey::Claimable(coop_id, token.clone(), member.clone()); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + assert!(amount > 0, "nothing to claim"); + env.storage().persistent().set(&key, &0i128); + token::Client::new(&env, &token).transfer( + &env.current_contract_address(), + &member, + &amount, + ); + env.events().publish( + (symbol_short!("split"), symbol_short!("coop_clm"), coop_id), + (member, token, amount), + ); + amount + } + + /// Fetch a cooperative. + pub fn get_cooperative(env: Env, coop_id: u64) -> Cooperative { + load_coop(&env, coop_id) + } + + /// Unclaimed profit for a member in `token`. + pub fn get_coop_claimable(env: Env, coop_id: u64, token: Address, member: Address) -> i128 { + env.storage() + .persistent() + .get(&CoopKey::Claimable(coop_id, token, member)) + .unwrap_or(0) + } + + /// Total profit ever deposited into a cooperative in `token`. + pub fn get_coop_total_deposited(env: Env, coop_id: u64, token: Address) -> i128 { + env.storage() + .persistent() + .get(&CoopKey::Deposited(coop_id, token)) + .unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; + use soroban_sdk::vec; + + fn setup() -> (Env, SplitContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(SplitContract, ()); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + (env.clone(), SplitContractClient::new(&env, &id), token) + } + + #[test] + fn test_deposit_splits_by_share_and_claim() { + let (env, c, token) = setup(); + let (a, b, payer) = ( + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ); + StellarAssetClient::new(&env, &token).mint(&payer, &1_001); + let id = c.create_cooperative(&a, &vec![&env, a.clone(), b.clone()], &vec![&env, 7_000u32, 3_000u32]); + c.deposit_coop_profit(&payer, &id, &token, &1_001); + assert_eq!(c.get_coop_claimable(&id, &token, &a), 700); + assert_eq!(c.get_coop_claimable(&id, &token, &b), 301); + assert_eq!(c.claim_coop_profit(&b, &id, &token), 301); + assert_eq!(TokenClient::new(&env, &token).balance(&b), 301); + assert_eq!(c.get_coop_claimable(&id, &token, &b), 0); + assert_eq!(c.get_coop_total_deposited(&id, &token), 1_001); + } + + #[test] + #[should_panic(expected = "shares must sum to 10000 bps")] + fn test_bad_share_sum_rejected() { + let (env, c, _t) = setup(); + let a = Address::generate(&env); + c.create_cooperative(&a, &vec![&env, a.clone()], &vec![&env, 9_000u32]); + } + + #[test] + #[should_panic(expected = "duplicate member")] + fn test_duplicate_member_rejected() { + let (env, c, _t) = setup(); + let a = Address::generate(&env); + c.create_cooperative(&a, &vec![&env, a.clone(), a.clone()], &vec![&env, 5_000u32, 5_000u32]); + } + + #[test] + #[should_panic(expected = "not a cooperative member")] + fn test_non_member_cannot_claim() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + let id = c.create_cooperative(&a, &vec![&env, a.clone()], &vec![&env, 10_000u32]); + c.claim_coop_profit(&Address::generate(&env), &id, &token); + } + + #[test] + #[should_panic(expected = "nothing to claim")] + fn test_claim_with_nothing_rejected() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + let id = c.create_cooperative(&a, &vec![&env, a.clone()], &vec![&env, 10_000u32]); + c.claim_coop_profit(&a, &id, &token); + } +} diff --git a/contracts/split/src/education_lockup.rs b/contracts/split/src/education_lockup.rs new file mode 100644 index 0000000..e9a88b4 --- /dev/null +++ b/contracts/split/src/education_lockup.rs @@ -0,0 +1,245 @@ +//! Issue #891: Recipient education funding lockup. +//! +//! A funder locks tokens for a recipient with an approved education +//! institution. Funds cannot be withdrawn by the recipient directly: once the +//! release ledger is reached they can only be paid out to the institution. If +//! the lockup is not released before its expiry ledger, the funder may reclaim. + +use crate::SplitContract; +#[allow(unused_imports)] +use crate::{SplitContractArgs, SplitContractClient}; +use soroban_sdk::{contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum EducationKey { + NextId, + Lockup(u64), + /// Lockup ids per recipient: Vec + ByRecipient(Address), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum LockupStatus { + Locked, + Released, + Reclaimed, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct EducationLockup { + pub id: u64, + pub funder: Address, + pub recipient: Address, + pub institution: Address, + pub token: Address, + pub amount: i128, + pub release_ledger: u32, + pub expiry_ledger: u32, + pub status: LockupStatus, +} + +fn load_lockup(env: &Env, id: u64) -> EducationLockup { + env.storage() + .persistent() + .get(&EducationKey::Lockup(id)) + .expect("lockup not found") +} + +fn save_lockup(env: &Env, l: &EducationLockup) { + env.storage() + .persistent() + .set(&EducationKey::Lockup(l.id), l); +} + +#[contractimpl] +impl SplitContract { + /// Lock `amount` for `recipient`, payable only to `institution` once + /// `release_ledger` is reached. Returns the lockup id. + pub fn create_education_lockup( + env: Env, + funder: Address, + recipient: Address, + institution: Address, + token: Address, + amount: i128, + release_ledger: u32, + expiry_ledger: u32, + ) -> u64 { + funder.require_auth(); + assert!(amount > 0, "amount must be positive"); + assert!( + release_ledger > env.ledger().sequence(), + "release ledger must be in the future" + ); + assert!(expiry_ledger > release_ledger, "expiry must follow release"); + token::Client::new(&env, &token).transfer( + &funder, + &env.current_contract_address(), + &amount, + ); + let id: u64 = env + .storage() + .persistent() + .get(&EducationKey::NextId) + .unwrap_or(1u64); + env.storage().persistent().set(&EducationKey::NextId, &(id + 1)); + let lockup = EducationLockup { + id, + funder: funder.clone(), + recipient: recipient.clone(), + institution: institution.clone(), + token, + amount, + release_ledger, + expiry_ledger, + status: LockupStatus::Locked, + }; + save_lockup(&env, &lockup); + let rkey = EducationKey::ByRecipient(recipient.clone()); + let mut ids: Vec = env.storage().persistent().get(&rkey).unwrap_or(Vec::new(&env)); + ids.push_back(id); + env.storage().persistent().set(&rkey, &ids); + env.events().publish( + (symbol_short!("split"), symbol_short!("edu_lock"), id), + (funder, recipient, institution, amount, release_ledger), + ); + id + } + + /// Recipient authorises payment of the locked funds to the institution. + /// Only valid between the release and expiry ledgers. + pub fn release_education_funds(env: Env, recipient: Address, lockup_id: u64) { + recipient.require_auth(); + let mut l = load_lockup(&env, lockup_id); + assert!(l.recipient == recipient, "not the lockup recipient"); + assert!(l.status == LockupStatus::Locked, "lockup not active"); + let now = env.ledger().sequence(); + assert!(now >= l.release_ledger, "lockup not yet releasable"); + assert!(now <= l.expiry_ledger, "lockup expired"); + token::Client::new(&env, &l.token).transfer( + &env.current_contract_address(), + &l.institution, + &l.amount, + ); + l.status = LockupStatus::Released; + save_lockup(&env, &l); + env.events().publish( + (symbol_short!("split"), symbol_short!("edu_rel"), lockup_id), + (l.institution, l.amount), + ); + } + + /// Funder reclaims an unreleased lockup after its expiry ledger. + pub fn reclaim_education_funds(env: Env, funder: Address, lockup_id: u64) { + funder.require_auth(); + let mut l = load_lockup(&env, lockup_id); + assert!(l.funder == funder, "not the lockup funder"); + assert!(l.status == LockupStatus::Locked, "lockup not active"); + assert!(env.ledger().sequence() > l.expiry_ledger, "lockup not yet expired"); + token::Client::new(&env, &l.token).transfer( + &env.current_contract_address(), + &funder, + &l.amount, + ); + l.status = LockupStatus::Reclaimed; + save_lockup(&env, &l); + env.events().publish( + (symbol_short!("split"), symbol_short!("edu_recl"), lockup_id), + (funder, l.amount), + ); + } + + /// Fetch a lockup by id. + pub fn get_education_lockup(env: Env, lockup_id: u64) -> EducationLockup { + load_lockup(&env, lockup_id) + } + + /// Lockup ids created for a recipient. + pub fn get_recipient_education_lockups(env: Env, recipient: Address) -> Vec { + env.storage() + .persistent() + .get(&EducationKey::ByRecipient(recipient)) + .unwrap_or(Vec::new(&env)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; + + struct Ctx { + env: Env, + c: SplitContractClient<'static>, + token: Address, + funder: Address, + recipient: Address, + school: Address, + id: u64, + } + + fn setup() -> Ctx { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(SplitContract, ()); + let c = SplitContractClient::new(&env, &cid); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + let (funder, recipient, school) = ( + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ); + StellarAssetClient::new(&env, &token).mint(&funder, &1_000); + let now = env.ledger().sequence(); + let id = c.create_education_lockup( + &funder, &recipient, &school, &token, &1_000, &(now + 10), &(now + 100), + ); + Ctx { env, c, token, funder, recipient, school, id } + } + + #[test] + fn test_release_pays_institution() { + let x = setup(); + x.env.ledger().set_sequence_number(x.env.ledger().sequence() + 10); + x.c.release_education_funds(&x.recipient, &x.id); + assert_eq!(TokenClient::new(&x.env, &x.token).balance(&x.school), 1_000); + assert_eq!(x.c.get_education_lockup(&x.id).status, LockupStatus::Released); + assert_eq!(x.c.get_recipient_education_lockups(&x.recipient).len(), 1); + } + + #[test] + #[should_panic(expected = "lockup not yet releasable")] + fn test_release_before_ledger_rejected() { + let x = setup(); + x.c.release_education_funds(&x.recipient, &x.id); + } + + #[test] + fn test_reclaim_after_expiry() { + let x = setup(); + x.env.ledger().set_sequence_number(x.env.ledger().sequence() + 101); + x.c.reclaim_education_funds(&x.funder, &x.id); + assert_eq!(TokenClient::new(&x.env, &x.token).balance(&x.funder), 1_000); + } + + #[test] + #[should_panic(expected = "lockup not yet expired")] + fn test_reclaim_before_expiry_rejected() { + let x = setup(); + x.c.reclaim_education_funds(&x.funder, &x.id); + } + + #[test] + #[should_panic(expected = "lockup expired")] + fn test_release_after_expiry_rejected() { + let x = setup(); + x.env.ledger().set_sequence_number(x.env.ledger().sequence() + 101); + x.c.release_education_funds(&x.recipient, &x.id); + } +} diff --git a/contracts/split/src/insurance_pool.rs b/contracts/split/src/insurance_pool.rs new file mode 100644 index 0000000..a556f47 --- /dev/null +++ b/contracts/split/src/insurance_pool.rs @@ -0,0 +1,323 @@ +//! Issue #888: Recipient insurance pool governance. +//! +//! Members fund a shared pool per token. Recipients file claims against the +//! pool and members vote on them, weighted by their net contribution. A claim +//! is payable once approving weight exceeds half of the total contributions. + +use crate::SplitContract; +#[allow(unused_imports)] +use crate::{SplitContractArgs, SplitContractClient}; +use soroban_sdk::{ + contractimpl, contracttype, symbol_short, token, Address, Env, Map, String, +}; + +/// Voting period for a claim, in ledgers. +const CLAIM_VOTING_LEDGERS: u32 = 120_960; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum InsuranceKey { + /// Pool balance per token: i128 + Balance(Address), + /// Member contribution per (token, member): i128 + Contribution(Address, Address), + /// Total contributions per token: i128 + TotalContrib(Address), + /// Next claim id: u64 + NextClaim, + /// Claim by id + Claim(u64), + /// Votes cast on a claim: Map + Votes(u64), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ClaimStatus { + Open, + Paid, + Rejected, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct InsuranceClaim { + pub id: u64, + pub token: Address, + pub claimant: Address, + pub amount: i128, + pub reason: String, + pub invoice_id: u64, + pub approve_weight: i128, + pub reject_weight: i128, + pub voting_ends_ledger: u32, + pub status: ClaimStatus, +} + +fn get_i128(env: &Env, key: &InsuranceKey) -> i128 { + env.storage().persistent().get(key).unwrap_or(0) +} + +fn load_claim(env: &Env, claim_id: u64) -> InsuranceClaim { + env.storage() + .persistent() + .get(&InsuranceKey::Claim(claim_id)) + .expect("claim not found") +} + +#[contractimpl] +impl SplitContract { + /// Contribute `amount` of `token` to the insurance pool. Contribution + /// grants proportional voting weight on claims. + pub fn fund_insurance_pool(env: Env, member: Address, token: Address, amount: i128) { + member.require_auth(); + assert!(amount > 0, "amount must be positive"); + token::Client::new(&env, &token).transfer( + &member, + &env.current_contract_address(), + &amount, + ); + let bal_key = InsuranceKey::Balance(token.clone()); + let c_key = InsuranceKey::Contribution(token.clone(), member.clone()); + let t_key = InsuranceKey::TotalContrib(token.clone()); + env.storage() + .persistent() + .set(&bal_key, &(get_i128(&env, &bal_key) + amount)); + env.storage() + .persistent() + .set(&c_key, &(get_i128(&env, &c_key) + amount)); + env.storage() + .persistent() + .set(&t_key, &(get_i128(&env, &t_key) + amount)); + env.events().publish( + (symbol_short!("split"), symbol_short!("ins_fund")), + (member, token, amount), + ); + } + + /// File a claim against the pool on behalf of a recipient. Returns the claim id. + pub fn file_insurance_claim( + env: Env, + claimant: Address, + token: Address, + amount: i128, + invoice_id: u64, + reason: String, + ) -> u64 { + claimant.require_auth(); + assert!(amount > 0, "amount must be positive"); + let balance = get_i128(&env, &InsuranceKey::Balance(token.clone())); + assert!(amount <= balance, "claim exceeds pool balance"); + let id: u64 = env + .storage() + .persistent() + .get(&InsuranceKey::NextClaim) + .unwrap_or(1u64); + env.storage() + .persistent() + .set(&InsuranceKey::NextClaim, &(id + 1)); + let claim = InsuranceClaim { + id, + token: token.clone(), + claimant: claimant.clone(), + amount, + reason, + invoice_id, + approve_weight: 0, + reject_weight: 0, + voting_ends_ledger: env.ledger().sequence() + CLAIM_VOTING_LEDGERS, + status: ClaimStatus::Open, + }; + env.storage() + .persistent() + .set(&InsuranceKey::Claim(id), &claim); + env.events().publish( + (symbol_short!("split"), symbol_short!("ins_claim"), id), + (claimant, token, amount, invoice_id), + ); + id + } + + /// Vote on an open claim. Weight equals the voter's pool contribution. + pub fn vote_insurance_claim(env: Env, voter: Address, claim_id: u64, approve: bool) { + voter.require_auth(); + let mut claim = load_claim(&env, claim_id); + assert!(claim.status == ClaimStatus::Open, "claim not open"); + assert!( + env.ledger().sequence() <= claim.voting_ends_ledger, + "voting period ended" + ); + let weight = get_i128( + &env, + &InsuranceKey::Contribution(claim.token.clone(), voter.clone()), + ); + assert!(weight > 0, "not a pool member"); + let votes_key = InsuranceKey::Votes(claim_id); + let mut votes: Map = env + .storage() + .persistent() + .get(&votes_key) + .unwrap_or(Map::new(&env)); + assert!(!votes.contains_key(voter.clone()), "already voted"); + votes.set(voter.clone(), approve); + env.storage().persistent().set(&votes_key, &votes); + if approve { + claim.approve_weight += weight; + } else { + claim.reject_weight += weight; + } + env.storage() + .persistent() + .set(&InsuranceKey::Claim(claim_id), &claim); + env.events().publish( + (symbol_short!("split"), symbol_short!("ins_vote"), claim_id), + (voter, approve, weight), + ); + } + + /// Settle a claim: pays out when approving weight exceeds half of total + /// contributions, rejects when rejecting weight does (or voting expired + /// without a majority). + pub fn execute_insurance_claim(env: Env, claim_id: u64) { + let mut claim = load_claim(&env, claim_id); + assert!(claim.status == ClaimStatus::Open, "claim not open"); + let total = get_i128(&env, &InsuranceKey::TotalContrib(claim.token.clone())); + if claim.approve_weight * 2 > total { + let bal_key = InsuranceKey::Balance(claim.token.clone()); + let balance = get_i128(&env, &bal_key); + assert!(claim.amount <= balance, "claim exceeds pool balance"); + env.storage() + .persistent() + .set(&bal_key, &(balance - claim.amount)); + token::Client::new(&env, &claim.token).transfer( + &env.current_contract_address(), + &claim.claimant, + &claim.amount, + ); + claim.status = ClaimStatus::Paid; + env.events().publish( + (symbol_short!("split"), symbol_short!("ins_paid"), claim_id), + (claim.claimant.clone(), claim.amount), + ); + } else if claim.reject_weight * 2 >= total + || env.ledger().sequence() > claim.voting_ends_ledger + { + claim.status = ClaimStatus::Rejected; + env.events().publish( + (symbol_short!("split"), symbol_short!("ins_rej"), claim_id), + claim.claimant.clone(), + ); + } else { + panic!("claim undecided"); + } + env.storage() + .persistent() + .set(&InsuranceKey::Claim(claim_id), &claim); + } + + /// Current pool balance for a token. + pub fn get_insurance_pool_balance(env: Env, token: Address) -> i128 { + get_i128(&env, &InsuranceKey::Balance(token)) + } + + /// A member's contribution to the pool for a token. + pub fn get_insurance_contribution(env: Env, token: Address, member: Address) -> i128 { + get_i128(&env, &InsuranceKey::Contribution(token, member)) + } + + /// Fetch a claim by id. + pub fn get_insurance_claim(env: Env, claim_id: u64) -> InsuranceClaim { + load_claim(&env, claim_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::token::StellarAssetClient; + + fn setup() -> (Env, SplitContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(SplitContract, ()); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + let c = SplitContractClient::new(&env, &id); + (env, c, token) + } + + fn mint(env: &Env, token: &Address, to: &Address, amt: i128) { + StellarAssetClient::new(env, token).mint(to, &amt); + } + + #[test] + fn test_fund_and_claim_paid_by_majority() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + let b = Address::generate(&env); + let claimant = Address::generate(&env); + mint(&env, &token, &a, 600); + mint(&env, &token, &b, 400); + c.fund_insurance_pool(&a, &token, &600); + c.fund_insurance_pool(&b, &token, &400); + assert_eq!(c.get_insurance_pool_balance(&token), 1000); + + let id = c.file_insurance_claim(&claimant, &token, &300, &1, &String::from_str(&env, "loss")); + c.vote_insurance_claim(&a, &id, &true); + c.execute_insurance_claim(&id); + assert_eq!(c.get_insurance_pool_balance(&token), 700); + assert_eq!(c.get_insurance_claim(&id).status, ClaimStatus::Paid); + } + + #[test] + #[should_panic(expected = "claim undecided")] + fn test_minority_vote_undecided() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + let b = Address::generate(&env); + mint(&env, &token, &a, 300); + mint(&env, &token, &b, 700); + c.fund_insurance_pool(&a, &token, &300); + c.fund_insurance_pool(&b, &token, &700); + let id = c.file_insurance_claim(&a, &token, &100, &1, &String::from_str(&env, "x")); + c.vote_insurance_claim(&a, &id, &true); + c.execute_insurance_claim(&id); + } + + #[test] + fn test_expired_claim_rejected() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + mint(&env, &token, &a, 100); + c.fund_insurance_pool(&a, &token, &100); + let id = c.file_insurance_claim(&a, &token, &50, &1, &String::from_str(&env, "x")); + env.ledger().set_sequence_number(env.ledger().sequence() + CLAIM_VOTING_LEDGERS + 1); + c.execute_insurance_claim(&id); + assert_eq!(c.get_insurance_claim(&id).status, ClaimStatus::Rejected); + } + + #[test] + #[should_panic(expected = "not a pool member")] + fn test_non_member_cannot_vote() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + mint(&env, &token, &a, 100); + c.fund_insurance_pool(&a, &token, &100); + let id = c.file_insurance_claim(&a, &token, &50, &1, &String::from_str(&env, "x")); + c.vote_insurance_claim(&Address::generate(&env), &id, &true); + } + + #[test] + #[should_panic(expected = "already voted")] + fn test_double_vote_rejected() { + let (env, c, token) = setup(); + let a = Address::generate(&env); + mint(&env, &token, &a, 100); + c.fund_insurance_pool(&a, &token, &100); + let id = c.file_insurance_claim(&a, &token, &50, &1, &String::from_str(&env, "x")); + c.vote_insurance_claim(&a, &id, &true); + c.vote_insurance_claim(&a, &id, &true); + } +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index ab78604..b1082f3 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -73,6 +73,11 @@ mod storage_keys; mod migrations; +mod insurance_pool; +mod sustainability; +mod cooperative; +mod education_lockup; + use error::ContractError; use validation::assert_valid_bps; use calc::{calc_platform_fee, funding_bps}; diff --git a/contracts/split/src/sustainability.rs b/contracts/split/src/sustainability.rs new file mode 100644 index 0000000..b834f56 --- /dev/null +++ b/contracts/split/src/sustainability.rs @@ -0,0 +1,176 @@ +//! Issue #889: Invoice sustainability impact tracking. +//! +//! Invoice creators record sustainability impact (e.g. grams of CO2 offset, +//! trees planted) against their invoices, grouped by category. Per-invoice and +//! global running totals are maintained and an event is emitted per record. + +use crate::SplitContract; +#[allow(unused_imports)] +use crate::{SplitContractArgs, SplitContractClient}; +use soroban_sdk::{contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec}; + +/// Maximum distinct impact categories tracked per invoice. +const MAX_IMPACT_CATEGORIES: u32 = 20; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum SustainabilityKey { + /// Impact total per (invoice, category): i128 + Impact(u64, Symbol), + /// Categories recorded for an invoice: Vec + Categories(u64), + /// Global total per category: i128 + GlobalImpact(Symbol), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ImpactEntry { + pub category: Symbol, + pub value: i128, +} + +#[contractimpl] +impl SplitContract { + /// Record a sustainability impact `value` in `category` for an invoice. + /// Only the invoice creator may record. Values accumulate. + pub fn record_sustainability_impact( + env: Env, + creator: Address, + invoice_id: u64, + category: Symbol, + value: i128, + ) { + creator.require_auth(); + assert!(value > 0, "impact value must be positive"); + let invoice = crate::load_invoice(&env, invoice_id); + assert!(invoice.creator == creator, "only creator can record impact"); + + let mut cats: Vec = env + .storage() + .persistent() + .get(&SustainabilityKey::Categories(invoice_id)) + .unwrap_or(Vec::new(&env)); + if !cats.contains(category.clone()) { + assert!(cats.len() < MAX_IMPACT_CATEGORIES, "too many impact categories"); + cats.push_back(category.clone()); + env.storage() + .persistent() + .set(&SustainabilityKey::Categories(invoice_id), &cats); + } + + let key = SustainabilityKey::Impact(invoice_id, category.clone()); + let total: i128 = env.storage().persistent().get(&key).unwrap_or(0) + value; + env.storage().persistent().set(&key, &total); + + let gkey = SustainabilityKey::GlobalImpact(category.clone()); + let global: i128 = env.storage().persistent().get(&gkey).unwrap_or(0) + value; + env.storage().persistent().set(&gkey, &global); + + env.events().publish( + (symbol_short!("split"), symbol_short!("sust_rec"), invoice_id), + (category, value, total), + ); + } + + /// Impact total for one category on an invoice. + pub fn get_invoice_impact(env: Env, invoice_id: u64, category: Symbol) -> i128 { + env.storage() + .persistent() + .get(&SustainabilityKey::Impact(invoice_id, category)) + .unwrap_or(0) + } + + /// All recorded impact entries for an invoice. + pub fn get_invoice_impact_report(env: Env, invoice_id: u64) -> Vec { + let cats: Vec = env + .storage() + .persistent() + .get(&SustainabilityKey::Categories(invoice_id)) + .unwrap_or(Vec::new(&env)); + let mut out = Vec::new(&env); + for category in cats.iter() { + let value: i128 = env + .storage() + .persistent() + .get(&SustainabilityKey::Impact(invoice_id, category.clone())) + .unwrap_or(0); + out.push_back(ImpactEntry { category, value }); + } + out + } + + /// Global impact total for a category across all invoices. + pub fn get_global_impact(env: Env, category: Symbol) -> i128 { + env.storage() + .persistent() + .get(&SustainabilityKey::GlobalImpact(category)) + .unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::default_options; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{vec, Env}; + + fn setup() -> (Env, SplitContractClient<'static>, Address, u64) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let id = env.register(SplitContract, ()); + let c = SplitContractClient::new(&env, &id); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + c.initialize( + &Address::generate(&env), + &0_i128, + &Address::generate(&env), + &token, + &0_u32, + &None, + &0_u32, + &0_u32, + &0_u64, + ); + let creator = Address::generate(&env); + let invoice_id = c.create_invoice( + &creator, + &vec![&env, Address::generate(&env)], + &vec![&env, 100_i128], + &token, + &100_000_u64, + &default_options(&env), + ); + (env, c, creator, invoice_id) + } + + #[test] + fn test_record_accumulates_and_reports() { + let (_env, c, creator, id) = setup(); + let co2 = symbol_short!("co2"); + c.record_sustainability_impact(&creator, &id, &co2, &50); + c.record_sustainability_impact(&creator, &id, &co2, &25); + c.record_sustainability_impact(&creator, &id, &symbol_short!("trees"), &3); + assert_eq!(c.get_invoice_impact(&id, &co2), 75); + assert_eq!(c.get_global_impact(&co2), 75); + assert_eq!(c.get_invoice_impact_report(&id).len(), 2); + } + + #[test] + #[should_panic(expected = "only creator can record impact")] + fn test_non_creator_rejected() { + let (env, c, _creator, id) = setup(); + c.record_sustainability_impact(&Address::generate(&env), &id, &symbol_short!("co2"), &1); + } + + #[test] + #[should_panic(expected = "impact value must be positive")] + fn test_zero_value_rejected() { + let (_env, c, creator, id) = setup(); + c.record_sustainability_impact(&creator, &id, &symbol_short!("co2"), &0); + } +} diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 4bd3a9f..350a323 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -41,7 +41,7 @@ fn token_client<'a>(env: &'a Env, token_id: &Address) -> TokenClient<'a> { TokenClient::new(env, token_id) } -fn default_options(env: &Env) -> InvoiceOptions { +pub(crate) fn default_options(env: &Env) -> InvoiceOptions { InvoiceOptions { co_creators: Vec::new(env), allow_early_withdrawal: false,