From b6ef30a74e9b8878f0e836a334b522c987ae9b28 Mon Sep 17 00:00:00 2001 From: ugoocreates-pixel Date: Sun, 30 Aug 2026 07:48:52 +0100 Subject: [PATCH] feat: enforce vesting cliff_secs gate before pro-rata accrual Closes #28 --- src/vesting.rs | 13 ++++++++++++ src/vesting_test.rs | 52 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/vesting.rs b/src/vesting.rs index 861cea80..e8ab74ae 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -62,6 +62,7 @@ pub struct VestingSchedule { pub end_ts: u64, pub curve: VestingCurve, pub accelerated_amount: i128, + pub cliff_secs: u64, } /// Errors produced by the vesting module. @@ -87,6 +88,8 @@ pub enum VestingError { AlreadyAccelerated = 107, /// Acceleration bps must not exceed 10000. InvalidAccelerationBps = 108, + /// Vesting cliff period has not been reached yet. + VestingCliffNotReached = 109, } /// Shared schema version for vesting events. @@ -113,6 +116,7 @@ impl VestingContract { start_ts: u64, end_ts: u64, curve: VestingCurve, + cliff_secs: u64, ) -> Result<(), VestingError> { issuer.require_auth(); @@ -161,7 +165,9 @@ impl VestingContract { cliff_ts, start_ts, end_ts, + curve, accelerated_amount: 0, + cliff_secs, }; env.storage().persistent().set(&key, &schedule); env.storage().persistent().set(&VestingKey::Claimed(beneficiary.clone()), &0_i128); @@ -232,6 +238,9 @@ impl VestingContract { let already_claimed: i128 = env.storage().persistent().get(&claimed_key).unwrap_or(0_i128); let now = env.ledger().timestamp(); + if now < schedule.start_ts.saturating_add(schedule.cliff_secs) { + return Err(VestingError::VestingCliffNotReached); + } if now < schedule.cliff_ts { return Err(VestingError::NothingToClaimYet); } @@ -409,6 +418,10 @@ pub fn migrate_offering_schedules( /// Helper: compute total vested tokens at a given timestamp. fn compute_vested(schedule: &VestingSchedule, now: u64) -> i128 { + if now < schedule.start_ts.saturating_add(schedule.cliff_secs) { + return 0; + } + let base_vested = match &schedule.curve { VestingCurve::Graded(milestones) => { if now < schedule.cliff_ts { diff --git a/src/vesting_test.rs b/src/vesting_test.rs index ad966a23..a5f918d2 100644 --- a/src/vesting_test.rs +++ b/src/vesting_test.rs @@ -451,7 +451,7 @@ fn test_accelerate_vesting_idempotent() { let beneficiary = Address::generate(&env); let token = Address::generate(&env); - client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &200, &1200, &VestingCurve::Linear); + client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &200, &1200, &VestingCurve::Linear, &0); let trigger_id = symbol_short!("acq"); client.accelerate_vesting(&beneficiary, &trigger_id, &5000u32); @@ -472,7 +472,7 @@ fn test_accelerate_vesting_invalid_bps() { let beneficiary = Address::generate(&env); let token = Address::generate(&env); - client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &200, &1200, &VestingCurve::Linear); + client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &200, &1200, &VestingCurve::Linear, &0); let trigger_id = symbol_short!("ipo"); @@ -495,7 +495,7 @@ fn test_vested_progress_at() { // Register 1000 tokens, cliff at 100, start at 100, end at 1100 // Total duration = 1000. So 1 token per 1 tick. - client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &100, &1100, &VestingCurve::Linear); + client.vesting_register(&issuer, &beneficiary, &token, &1000, &100, &100, &1100, &VestingCurve::Linear, &0); // Test at cliff - 1 (ts: 99). Vested = 0. BPS = 0 let progress_before_cliff = client.vested_progress_at(&offering_id, &beneficiary, &99); @@ -513,3 +513,49 @@ fn test_vested_progress_at() { let progress_after_end = client.vested_progress_at(&offering_id, &beneficiary, &1101); assert_eq!(progress_after_end, 10000); } + +#[test] +fn test_cliff_secs_gate() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token = Address::generate(&env); + + // Total 1000, start 100, end 1100. Cliff secs = 50. + client.vesting_register( + &issuer, + &beneficiary, + &token, + &1000, + &100, // cliff_ts + &100, // start_ts + &1100, // end_ts + &VestingCurve::Linear, + &50 // cliff_secs + ); + + // At ts 149 (before start + cliff_secs) + env.ledger().with_mut(|l| l.timestamp = 149); + let claimable_err = client.try_vesting_claim(&beneficiary).unwrap_err(); + assert_eq!(claimable_err.unwrap().unwrap(), VestingError::VestingCliffNotReached as u32); + + let progress = client.vested_progress_at(&VestingOfferingId { issuer: issuer.clone(), token: token.clone() }, &beneficiary, &149); + assert_eq!(progress, 0); + + let vested_amt = client.get_vested_amount(&beneficiary).unwrap(); + assert_eq!(vested_amt, 0); + + // At ts 150 (exactly start + cliff_secs) + env.ledger().with_mut(|l| l.timestamp = 150); + let progress_at_cliff = client.vested_progress_at(&VestingOfferingId { issuer: issuer.clone(), token: token.clone() }, &beneficiary, &150); + // Vested should be 50 tokens (since 1000 duration, 1 token per sec, 50 secs elapsed). + // BPS = 50 / 1000 = 5% = 500 + assert_eq!(progress_at_cliff, 500); + + let vested_amt_at_cliff = client.get_vested_amount(&beneficiary).unwrap(); + assert_eq!(vested_amt_at_cliff, 50); +}