From 6a560f92b7690a70babbf9afed8a3a3b11b50645 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Thu, 20 Aug 2026 09:15:40 +0100 Subject: [PATCH] feat: add multi-recipient dispute escrow splitting with multi-sig arbitration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement #76 — allow arbitrators to split escrowed dispute funds among multiple recipients according to negotiated percentage shares, with multi-sig threshold verification for execution. Changes: - Add DisputeSplitRecipient and DisputeSplit types for fractional splits - Add resolve_dispute_split entry point supporting N-recipient splits via basis-point shares (must sum to 10000 = 100%) - Platform fee is deducted from total before proportional distribution - Add ResolveDisputeSplit variant to MultisigAction for threshold-gated arbitration — requires configured number of signer approvals before execution - Add emit_dispute_split_resolved event for off-chain indexing - Validate split invariants: non-empty recipients, matching lengths, shares sum to 10000 bps, task must be in Disputed status Tests: - test_dispute_split_three_way: 3 recipients, 40/30/30 split with 10% fee - test_dispute_split_zero_fee: 3 recipients, 25/25/50 with 0% fee - test_dispute_split_rejects_non_disputed: rejects on non-disputed task - test_dispute_split_rejects_invalid_shares: rejects when shares != 10000 - test_dispute_split_rejects_mismatched_lengths: rejects length mismatch - test_dispute_split_via_multisig_proposal: end-to-end multisig 2-of-3 approval triggering auto-executed split resolution --- src/events.rs | 7 ++ src/lib.rs | 140 ++++++++++++++++++++++++ src/multisig.rs | 22 ++++ src/test.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 454 insertions(+) diff --git a/src/events.rs b/src/events.rs index 3b2c4f8..5557493 100644 --- a/src/events.rs +++ b/src/events.rs @@ -61,6 +61,13 @@ pub fn emit_dispute_resolved(env: &Env, task_id: u32, creator_refund: i128, assi ); } +pub fn emit_dispute_split_resolved(env: &Env, task_id: u32, platform_fee: i128, distributable: i128) { + env.events().publish( + (symbol_short!("disp_splt"), task_id), + (platform_fee, distributable, env.ledger().timestamp()), + ); +} + // ── Profile Events ───────────────────────────────────────────────────────── pub fn emit_profile_created(env: &Env, user: Address, username: String) { diff --git a/src/lib.rs b/src/lib.rs index f4a2d7b..2cc140e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,27 @@ pub enum DataKey { GovernanceConfig, } +// ============================================================================ +// Dispute Split Types +// ============================================================================ + +/// A single recipient share in a dispute split. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeSplitRecipient { + pub address: Address, + /// Percentage in basis points (100 = 1%, 10000 = 100%). + pub share_bps: u32, +} + +/// The full split instruction attached to a dispute resolution. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeSplit { + /// Ordered list of recipients and their basis-point shares. + pub recipients: Vec, +} + // ============================================================================ // Main Contract // ============================================================================ @@ -564,6 +585,19 @@ impl TaskManagerContract { events::emit_dispute_resolved(&env, task_id, creator_refund, assignee_payout); } + /// Resolve a disputed task by splitting the escrowed funds among multiple + /// recipients according to percentage shares. + /// + /// Delegates to the module-level `resolve_dispute_split` function. + pub fn resolve_dispute_split( + env: Env, + task_id: u32, + recipients: Vec
, + shares_bps: Vec, + ) { + crate::resolve_dispute_split(env, task_id, recipients, shares_bps); + } + // ======================================================================== // Milestone Management // ======================================================================== @@ -1191,6 +1225,112 @@ impl TaskManagerContract { } } +// ============================================================================ +// Dispute Split Resolution (module-level for multisig reuse) +// ============================================================================ + +/// Resolve a disputed task by splitting the escrowed funds among multiple +/// recipients according to percentage shares. +/// +/// - `task_id` – must be in `Disputed` status. +/// - `recipients` – non-empty list of payment recipients. +/// - `shares_bps` – basis-point share per recipient (must sum to 10000). +/// +/// Platform fee is deducted from the total before distribution. Each +/// recipient's final payout is computed proportionally from the +/// fee-reduced balance. +pub fn resolve_dispute_split( + env: Env, + task_id: u32, + recipients: Vec
, + shares_bps: Vec, +) { + // ── Basic invariants ──────────────────────────────────────────── + if recipients.len() == 0 { + panic!("recipients list cannot be empty"); + } + if recipients.len() != shares_bps.len() { + panic!("recipients and shares must have same length"); + } + + let mut total_bps: u32 = 0; + for i in 0..shares_bps.len() { + let bps = shares_bps.get(i).unwrap(); + total_bps = total_bps + .checked_add(bps) + .unwrap_or_else(|| panic!("share bps overflow")); + } + if total_bps != 10000 { + panic!("shares must sum to 10000 (100%)"); + } + + // ── Task state checks ────────────────────────────────────────── + let mut task: Task = env + .storage() + .instance() + .get(&DataKey::Task(task_id)) + .unwrap_or_else(|| panic!("task not found")); + + if task.status != TaskStatus::Disputed { + panic!("task is not disputed"); + } + + // ── Fee calculation ──────────────────────────────────────────── + let platform_fee_bps: u32 = env + .storage() + .instance() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0); + let fee_recipient: Address = env + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .unwrap(); + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); + let token_client = soroban_sdk::token::Client::new(&env, &token_contract); + + let fee = (task.reward * platform_fee_bps as i128) / 10000; + let distributable = task.reward - fee; + + // ── Distribute ───────────────────────────────────────────────── + for i in 0..recipients.len() { + let recipient = recipients.get(i).unwrap(); + let bps = shares_bps.get(i).unwrap(); + let payout = (distributable * bps as i128) / 10000; + if payout > 0 { + token_client.transfer( + &env.current_contract_address(), + &recipient, + &payout, + ); + } + } + + // Send the platform fee + if fee > 0 { + token_client.transfer( + &env.current_contract_address(), + &fee_recipient, + &fee, + ); + } + + // ── Release escrow ───────────────────────────────────────────── + escrow::release_escrow(env.clone(), task_id, task.reward); + + task.status = TaskStatus::Resolved; + task.updated_at = env.ledger().timestamp(); + env.storage() + .instance() + .set(&DataKey::Task(task_id), &task); + + events::emit_dispute_split_resolved(&env, task_id, fee, distributable); +} + // ============================================================================ // Helper Functions // ============================================================================ diff --git a/src/multisig.rs b/src/multisig.rs index 5bc36b9..173db32 100644 --- a/src/multisig.rs +++ b/src/multisig.rs @@ -72,6 +72,9 @@ pub enum MultisigAction { TreasuryTransfer(Address, Address, i128), /// Rotate the signer set and threshold: `(signers, threshold)`. SetSigners(Vec
, u32), + /// Resolve a disputed task with a multi-recipient split. + /// `(task_id, recipients, share_bps)`. + ResolveDisputeSplit(u32, Vec
, Vec), } /// A single signer's recorded approval — the on-chain ledger entry proving @@ -247,6 +250,22 @@ fn validate_action(env: &Env, action: &MultisigAction) { MultisigAction::SetSigners(signers, threshold) => { validate_signer_set(signers, *threshold); } + MultisigAction::ResolveDisputeSplit(_task_id, recipients, shares_bps) => { + if recipients.len() == 0 { + panic!("recipients list cannot be empty"); + } + if recipients.len() != shares_bps.len() { + panic!("recipients and shares must have same length"); + } + let mut total_bps: u32 = 0; + for i in 0..shares_bps.len() { + let bps = shares_bps.get(i).unwrap(); + total_bps = total_bps.checked_add(bps).unwrap_or_else(|| panic!("share bps overflow")); + } + if total_bps != 10000 { + panic!("shares must sum to 10000 (100%)"); + } + } MultisigAction::SetFeeRecipient(_) | MultisigAction::SetTokenContract(_) => { let _ = env; } @@ -472,6 +491,9 @@ fn apply_action(env: &Env, action: &MultisigAction) { config.threshold = *threshold; env.storage().instance().set(&MultisigKey::Config, &config); } + MultisigAction::ResolveDisputeSplit(task_id, recipients, shares_bps) => { + crate::resolve_dispute_split(env.clone(), *task_id, recipients.clone(), shares_bps.clone()); + } } } diff --git a/src/test.rs b/src/test.rs index 4f85bec..8fc97b6 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1230,3 +1230,288 @@ fn test_zkp_commitment_is_deterministic() { let second = compute_attestation_commitment(env.clone(), nullifier, signals); assert_eq!(first, second); } + +// ── Test: Dispute split with 3-way fractional payout ─────────────────────── + +#[test] +fn test_dispute_split_three_way() { + let env = Env::default(); + env.mock_all_auths(); + + // 10% platform fee so fee math is obvious + let (client, contract_id, _, token_contract, fee_recipient) = + setup_initialized_contract(&env, 1000); + + let creator = Address::generate(&env); + let a1 = Address::generate(&env); + let a2 = Address::generate(&env); + let _a3 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_contract).mint(&creator, &1000); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "Split Task"), + &String::from_str(&env, "3-way split"), + &1000, + &Vec::new(&env), + ); + client.assign_task(&a1, &task_id); + client.dispute_task(&creator, &task_id); + + // 40% / 30% / 30% + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(a1.clone()); + recipients.push_back(a2.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(4000u32); + shares.push_back(3000u32); + shares.push_back(3000u32); + + client.resolve_dispute_split(&task_id, &recipients, &shares); + + // fee = 1000 * 1000 / 10000 = 100 + // distributable = 900 + // creator: 900 * 4000 / 10000 = 360 + // a1: 900 * 3000 / 10000 = 270 + // a2: 900 * 3000 / 10000 = 270 + let token = soroban_sdk::token::Client::new(&env, &token_contract); + assert_eq!(token.balance(&fee_recipient), 100, "platform fee"); + assert_eq!(token.balance(&creator), 360, "creator 40% share"); + assert_eq!(token.balance(&a1), 270, "a1 30% share"); + assert_eq!(token.balance(&a2), 270, "a2 30% share"); + assert_eq!(token.balance(&contract_id), 0, "escrow fully drained"); +} + +// ── Test: Dispute split with zero fee ────────────────────────────────────── + +#[test] +fn test_dispute_split_zero_fee() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, contract_id, _, token_contract, _) = + setup_initialized_contract(&env, 0); // 0% fee + + let creator = Address::generate(&env); + let assignee = Address::generate(&env); + let mediator = Address::generate(&env); + + StellarAssetClient::new(&env, &token_contract).mint(&creator, &800); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "Zero Fee Split"), + &String::from_str(&env, "Split with no fee"), + &800, + &Vec::new(&env), + ); + client.assign_task(&assignee, &task_id); + client.dispute_task(&creator, &task_id); + + // 25% / 25% / 50% + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(assignee.clone()); + recipients.push_back(mediator.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(2500u32); + shares.push_back(2500u32); + shares.push_back(5000u32); + + client.resolve_dispute_split(&task_id, &recipients, &shares); + + let token = soroban_sdk::token::Client::new(&env, &token_contract); + assert_eq!(token.balance(&creator), 200, "creator 25%"); + assert_eq!(token.balance(&assignee), 200, "assignee 25%"); + assert_eq!(token.balance(&mediator), 400, "mediator 50%"); + assert_eq!(token.balance(&contract_id), 0); +} + +// ── Test: Dispute split rejects when task not disputed ───────────────────── + +#[test] +fn test_dispute_split_rejects_non_disputed() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _, _, token_contract, _) = setup_initialized_contract(&env, 100); + + let creator = Address::generate(&env); + let a1 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_contract).mint(&creator, &100); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "Normal Task"), + &String::from_str(&env, "Not disputed"), + &100, + &Vec::new(&env), + ); + client.assign_task(&a1, &task_id); + + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(a1.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(5000u32); + shares.push_back(5000u32); + + let res = client.try_resolve_dispute_split(&task_id, &recipients, &shares); + assert!(res.is_err(), "must reject when task is not disputed"); +} + +// ── Test: Dispute split rejects invalid share sum ────────────────────────── + +#[test] +fn test_dispute_split_rejects_invalid_shares() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _, _admin, token_contract, _) = setup_initialized_contract(&env, 0); + + let creator = Address::generate(&env); + let assignee = Address::generate(&env); + + StellarAssetClient::new(&env, &token_contract).mint(&creator, &500); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "Bad Shares"), + &String::from_str(&env, "shares don't add up"), + &500, + &Vec::new(&env), + ); + client.assign_task(&assignee, &task_id); + client.dispute_task(&creator, &task_id); + + // 30% + 30% = 60% — not 100% + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(assignee.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(3000u32); + shares.push_back(3000u32); + + let res = client.try_resolve_dispute_split(&task_id, &recipients, &shares); + assert!(res.is_err(), "must reject when shares don't sum to 10000"); +} + +// ── Test: Dispute split rejects mismatched lengths ───────────────────────── + +#[test] +fn test_dispute_split_rejects_mismatched_lengths() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _, _, token_contract, _) = setup_initialized_contract(&env, 0); + + let creator = Address::generate(&env); + let assignee = Address::generate(&env); + + StellarAssetClient::new(&env, &token_contract).mint(&creator, &500); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "Mismatch"), + &String::from_str(&env, "lengths differ"), + &500, + &Vec::new(&env), + ); + client.assign_task(&assignee, &task_id); + client.dispute_task(&creator, &task_id); + + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(assignee.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(10000u32); // only one share for two recipients + + let res = client.try_resolve_dispute_split(&task_id, &recipients, &shares); + assert!(res.is_err(), "must reject when lengths mismatch"); +} + +// ── Test: Dispute split via multisig proposal ────────────────────────────── + +#[test] +fn test_dispute_split_via_multisig_proposal() { + use crate::multisig::{MultisigAction, MultisigProposalStatus}; + + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TaskManagerContract); + let client = TaskManagerContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(token_admin.clone()); + let fee_recipient = Address::generate(&env); + + client.initialize(&admin, &200u32, &token, &fee_recipient); // 2% fee + + // Setup 2-of-3 multisig + let s0 = Address::generate(&env); + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + let mut signers = Vec::new(&env); + signers.push_back(s0.clone()); + signers.push_back(s1.clone()); + signers.push_back(s2.clone()); + client.configure_multisig(&admin, &signers, &2u32, &None, &None); + + // Create task + dispute + let creator = Address::generate(&env); + let assignee = Address::generate(&env); + + StellarAssetClient::new(&env, &token).mint(&creator, &1000); + + let task_id = client.create_task( + &creator, + &String::from_str(&env, "MS Split Task"), + &String::from_str(&env, "multisig split"), + &1000, + &Vec::new(&env), + ); + client.assign_task(&assignee, &task_id); + client.dispute_task(&creator, &task_id); + + // Propose split: 60% creator, 40% assignee + let mut recipients = Vec::new(&env); + recipients.push_back(creator.clone()); + recipients.push_back(assignee.clone()); + + let mut shares = Vec::new(&env); + shares.push_back(6000u32); + shares.push_back(4000u32); + + let desc = String::from_str(&env, "resolve 60/40"); + let action = MultisigAction::ResolveDisputeSplit(task_id, recipients, shares); + let proposal_id = client.multisig_propose(&s0, &desc, &action); + assert_eq!(proposal_id, 1); + + // First approval — still pending + let status = client.vote_proposal(&s0, &proposal_id); + assert_eq!(status, MultisigProposalStatus::Pending); + + // Second approval triggers auto-execute + let status = client.vote_proposal(&s1, &proposal_id); + assert_eq!(status, MultisigProposalStatus::Executed); + + // fee = 1000 * 200 / 10000 = 20 + // distributable = 980 + // creator: 980 * 6000 / 10000 = 588 + // assignee: 980 * 4000 / 10000 = 392 + let token_client = soroban_sdk::token::Client::new(&env, &token); + assert_eq!(token_client.balance(&fee_recipient), 20); + assert_eq!(token_client.balance(&creator), 588); + assert_eq!(token_client.balance(&assignee), 392); + assert_eq!(token_client.balance(&contract_id), 0); +}