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,