From 541585388ad18e7ef4a23c73ab8cbd5896dab11b Mon Sep 17 00:00:00 2001 From: akprinciple Date: Mon, 31 Aug 2026 01:28:38 +0100 Subject: [PATCH] feat: add linear-vesting recurring disbursement path with cliff and cap enforcement --- contracts/accord/src/lib.rs | 84 +++++++++++++++++++++++++++++++++--- contracts/accord/src/test.rs | 41 ++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/contracts/accord/src/lib.rs b/contracts/accord/src/lib.rs index f02633e8..862354eb 100644 --- a/contracts/accord/src/lib.rs +++ b/contracts/accord/src/lib.rs @@ -1241,6 +1241,61 @@ fn recurring_payment_due_at(schedule: &RecurringPaymentSchedule) -> Result Result { + let cliff_time = schedule.cliff.unwrap_or(schedule.start); + if now < cliff_time { + return Ok(0); + } + + let duration = match schedule.end { + Some(end_time) if end_time > schedule.start => { + end_time + .checked_sub(schedule.start) + .ok_or(ContractError::ArithmeticError)? + } + _ => return Ok(0), + }; + if duration == 0 { + return Ok(0); + } + + let elapsed = if now >= schedule.start { + now.checked_sub(schedule.start).ok_or(ContractError::ArithmeticError)? + } else { + return Ok(0); + }; + + let elapsed_i128 = i128::try_from(elapsed).map_err(|_| ContractError::ArithmeticError)?; + let duration_i128 = i128::try_from(duration).map_err(|_| ContractError::ArithmeticError)?; + let vested_total = { + let numerator = elapsed_i128 + .checked_mul(schedule.amount) + .ok_or(ContractError::ArithmeticError)?; + let raw = numerator + .checked_div(duration_i128) + .ok_or(ContractError::ArithmeticError)?; + + let cap = schedule.cap.unwrap_or(schedule.amount); + if cap > 0 && raw > cap { + cap + } else { + raw + } + }; + + let already_paid = schedule.total_disbursed; + if vested_total <= already_paid { + return Ok(0); + } + + vested_total + .checked_sub(already_paid) + .ok_or(ContractError::ArithmeticError) +} + // ─── Contract ──────────────────────────────────────────────────────────────── #[contract] @@ -1764,6 +1819,12 @@ impl AccordContract { let now = env.ledger().timestamp(); let due_at = recurring_payment_due_at(&schedule)?; + let disbursement_amount = if schedule.cliff.is_some() || schedule.end.is_some() { + linear_vesting_payout(&schedule, now)? + } else { + schedule.amount + }; + if now < due_at { return Err(ContractError::RecurringIntervalNotElapsed); } @@ -1772,24 +1833,33 @@ impl AccordContract { return Err(ContractError::RecurringPaymentComplete); } } + if disbursement_amount <= 0 { + return Err(ContractError::RecurringPaymentComplete); + } + let projected_total = schedule .total_disbursed - .checked_add(schedule.amount) + .checked_add(disbursement_amount) .ok_or(ContractError::ArithmeticError)?; if let Some(total_cap) = schedule.cap { if projected_total > total_cap { - return Err(ContractError::RecurringPaymentComplete); + let clamped = total_cap + .checked_sub(schedule.total_disbursed) + .ok_or(ContractError::ArithmeticError)?; + if clamped <= 0 { + return Err(ContractError::RecurringPaymentComplete); + } } } let token_client = token::Client::new(&env, &schedule.token); let treasury = env.current_contract_address(); let balance = token_client.balance(&treasury); - if balance < schedule.amount { + if balance < disbursement_amount { return Err(ContractError::TransferFailed); } if token_client - .try_transfer(&treasury, &schedule.recipient, &schedule.amount) + .try_transfer(&treasury, &schedule.recipient, &disbursement_amount) .is_err() { return Err(ContractError::TransferFailed); @@ -1803,11 +1873,11 @@ impl AccordContract { tracker.epoch }; let spent = if now > epoch.saturating_add(SPENDING_WINDOW) { - schedule.amount + disbursement_amount } else { tracker .spent - .checked_add(schedule.amount) + .checked_add(disbursement_amount) .ok_or(ContractError::ArithmeticError)? }; write_spent_tracker(&env, &schedule.proposer, &schedule.token, &SpentTracker { spent, epoch }); @@ -1826,7 +1896,7 @@ impl AccordContract { schedule_id, recipient: schedule.recipient.clone(), token: schedule.token.clone(), - amount: schedule.amount, + amount: disbursement_amount, total_disbursed: schedule.total_disbursed, periods_disbursed: schedule.periods_disbursed, }, diff --git a/contracts/accord/src/test.rs b/contracts/accord/src/test.rs index ea403c66..7617e636 100644 --- a/contracts/accord/src/test.rs +++ b/contracts/accord/src/test.rs @@ -8091,6 +8091,47 @@ fn test_get_claimable_amount() { assert_eq!(client.get_claimable_amount(&1), 1_000_000_i128); } +#[test] +fn linear_vesting_disbursement_uses_newly_vested_amount_after_cliff() { + let (env, client, owner_a, owner_b, owner_c, _, token_client) = setup(2); + let recipient = Address::generate(&env); + + let create_id = client.create_recurring_proposal( + &owner_a, + &recipient, + &token_client.address, + &10_000_000_i128, + &3600_u64, + &NOW, + &(NOW + 10_000), + &(NOW + 2_000), + &10_000_000_i128, + &RecurringKind::LinearVesting, + &str(&env, "Linear vesting test"), + &DEADLINE, + &ProposalCategory::Ops, + ); + client.approve(&owner_a, &create_id); + client.approve(&owner_b, &create_id); + client.execute(&owner_c, &create_id); + + set_timestamp(&env, NOW + 1_000); + assert_eq!(client.try_disburse_recurring(&1), Err(Ok(ContractError::RecurringIntervalNotElapsed))); + assert_eq!(client.get_recurring_payment(&1).total_disbursed, 0_i128); + + set_timestamp(&env, NOW + 5_000); + client.disburse_recurring(&1); + assert_eq!(client.get_recurring_payment(&1).total_disbursed, 5_000_000_i128); + + set_timestamp(&env, NOW + 7_500); + client.disburse_recurring(&1); + assert_eq!(client.get_recurring_payment(&1).total_disbursed, 7_500_000_i128); + + set_timestamp(&env, NOW + 20_000); + client.disburse_recurring(&1); + assert_eq!(client.get_recurring_payment(&1).total_disbursed, 10_000_000_i128); +} + #[test] fn test_get_recurring_payments_paged() { let (env, client, owner_a, owner_b, owner_c, _, token_client) = setup(2);