diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index badb0c9..f63bbec 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -475,6 +475,12 @@ impl SplitContract { claimed.push_back(0i128); } + // Issue #27: Initialize vesting cliff claimed tracking (all false). + let mut vesting_cliff_claimed: Vec = Vec::new(env); + for _ in recipients.iter() { + vesting_cliff_claimed.push_back(false); + } + // Issue #87: Increment referral count if referrer is provided. // (referrer is not yet wired into _create_invoice_inner; skipped) @@ -911,6 +917,80 @@ impl SplitContract { append_audit_entry(&env, invoice_id, symbol_short!("aprv"), approver); } + /// Claim vesting cliff share after cliff timestamp has passed (issue #27). + /// + /// Requires that the invoice status is Released and the cliff (if set) has passed. + /// Each recipient can claim exactly once. + pub fn claim(env: Env, invoice_id: u64, recipient: Address) { + require_not_paused(&env); + recipient.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + + assert!( + invoice.status == InvoiceStatus::Released, + "invoice not released" + ); + + // Find recipient index + let idx = invoice + .recipients + .iter() + .position(|r| r == recipient) + .expect("recipient not in invoice") as u32; + + // Check if already claimed + assert!( + !invoice.vesting_cliff_claimed.get(idx).unwrap(), + "recipient already claimed" + ); + + // Check cliff timestamp if set + if let Some(cliff) = invoice.vesting_cliff { + let now = env.ledger().timestamp(); + assert!(now >= cliff, "cliff not reached"); + } + + // Mark as claimed + invoice.vesting_cliff_claimed.set(idx, true); + save_invoice(&env, invoice_id, &invoice); + + // Transfer recipient's share + let amount = invoice.amounts.get(idx).unwrap(); + let total: i128 = invoice.amounts.iter().sum(); + let funded = invoice.funded; + let n = invoice.recipients.len() as u32; + + let proportional = if idx == n - 1 { + // Last recipient gets remainder + funded - { + let mut sum = 0i128; + for i in 0..idx { + let amt = invoice.amounts.get(i).unwrap(); + let prop = (amt as u128 * funded as u128 / total as u128) as i128; + sum += prop; + } + sum + } + } else { + (amount as u128 * funded as u128 / total as u128) as i128 + }; + + let platform_fee_bps: u32 = env + .storage() + .instance() + .get(&platform_fee_bps_key()) + .unwrap_or(0u32); + + let fee = (proportional as u128 * platform_fee_bps as u128 / 10_000u128) as i128; + let payout = proportional - fee; + + let token_client = token::Client::new(&env, &invoice.tokens.get(idx).expect("no token")); + token_client.transfer(&env.current_contract_address(), &recipient, &payout); + + append_audit_entry(&env, invoice_id, symbol_short!("claim"), &recipient); + } + /// Distribute tranches unlocked by the current ledger time (issue #23). fn _release_tranches(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) { let now = env.ledger().timestamp(); @@ -1119,6 +1199,16 @@ impl SplitContract { /// Issue #89: Returns stake to creator on successful release. /// Issue #41: Swaps recipient payout via DEX if swap_tokens[i] is set. fn _release_full(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) { + // Issue #27: If vesting cliff is set, just mark as Released without transferring funds + if invoice.vesting_cliff.is_some() { + invoice.status = InvoiceStatus::Released; + invoice.completion_time = Some(env.ledger().timestamp()); + save_invoice(env, invoice_id, invoice); + append_audit_entry(env, invoice_id, symbol_short!("release"), actor); + events::invoice_released(env, invoice_id, &invoice.recipients); + return; + } + let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 0795cdf..751cf4b 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -219,6 +219,12 @@ impl Invoice { /// Upgrade a legacy (pre-version) invoice to the current schema. /// New fields are filled with their default (empty / zero) values. pub fn from_legacy(old: LegacyInvoice, env: &Env) -> Self { + let num_recipients = old.recipients.len(); + let mut vesting_cliff_claimed = Vec::new(env); + for _ in 0..num_recipients { + vesting_cliff_claimed.push_back(false); + } + Invoice { version: 2, creator: old.creator,