//! Token vesting contracts for Synor. //! //! Implements token lockup with: //! - Cliff period (no tokens released) //! - Linear vesting (gradual release after cliff) //! - Revocation support (for employee departures) //! //! # Example Vesting Schedule //! //! ```text //! 4-year vesting with 1-year cliff: //! //! Tokens //! Released //! │ //! 100%│ ●──────── //! │ ●───● //! │ ●───● //! │ ●───● //! │ ●───● //! 25%│ ●───● //! │ ●────● //! 0%├─────● //! └──────┬──────┬──────┬──────┬──────► Time //! 1yr 2yr 3yr 4yr //! (cliff) //! ``` use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use synor_types::{Address, Hash256}; use thiserror::Error; /// Vesting schedule parameters. #[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] pub struct VestingSchedule { /// Total amount of tokens to vest. pub total_amount: u64, /// Cliff duration in seconds. pub cliff_duration: u64, /// Total vesting duration in seconds (including cliff). pub vesting_duration: u64, /// Start timestamp (Unix seconds). pub start_time: u64, /// Whether the schedule is revocable by the grantor. pub revocable: bool, } impl VestingSchedule { /// Creates a new vesting schedule. pub fn new( total_amount: u64, cliff_months: u32, vesting_months: u32, start_time: u64, revocable: bool, ) -> Self { let seconds_per_month = 30 * 24 * 60 * 60; // ~30 days VestingSchedule { total_amount, cliff_duration: cliff_months as u64 * seconds_per_month, vesting_duration: vesting_months as u64 * seconds_per_month, start_time, revocable, } } /// Standard 4-year vesting with 1-year cliff. pub fn standard_4year(total_amount: u64, start_time: u64) -> Self { Self::new(total_amount, 12, 48, start_time, true) } /// Advisor vesting (2-year with 6-month cliff). pub fn advisor(total_amount: u64, start_time: u64) -> Self { Self::new(total_amount, 6, 24, start_time, true) } /// Investor vesting (1-year linear, no cliff). pub fn investor(total_amount: u64, start_time: u64) -> Self { Self::new(total_amount, 0, 12, start_time, false) } /// Founder vesting (4-year with 1-year cliff, non-revocable). pub fn founder(total_amount: u64, start_time: u64) -> Self { Self::new(total_amount, 12, 48, start_time, false) } /// Calculates vested amount at a given timestamp. pub fn vested_at(&self, timestamp: u64) -> u64 { if timestamp < self.start_time { return 0; } let elapsed = timestamp - self.start_time; // Before cliff: nothing vested if elapsed < self.cliff_duration { return 0; } // After full vesting: everything vested if elapsed >= self.vesting_duration { return self.total_amount; } // Linear vesting between cliff and end // vested = total * elapsed / vesting_duration (self.total_amount as u128 * elapsed as u128 / self.vesting_duration as u128) as u64 } /// Returns the cliff end timestamp. pub fn cliff_end(&self) -> u64 { self.start_time + self.cliff_duration } /// Returns the vesting end timestamp. pub fn vesting_end(&self) -> u64 { self.start_time + self.vesting_duration } /// Checks if the cliff has passed. pub fn cliff_passed(&self, timestamp: u64) -> bool { timestamp >= self.cliff_end() } /// Checks if vesting is complete. pub fn fully_vested(&self, timestamp: u64) -> bool { timestamp >= self.vesting_end() } } /// Current state of a vesting contract. #[derive( Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, )] pub enum VestingState { /// Vesting is active. Active, /// Vesting was revoked by grantor. Revoked, /// All tokens have been claimed. Completed, } /// A vesting contract instance. #[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] pub struct VestingContract { /// Unique identifier (hash of creation params). pub id: Hash256, /// Beneficiary who receives the tokens. pub beneficiary: Address, /// Grantor who created the vesting (can revoke if revocable). pub grantor: Address, /// Vesting schedule parameters. pub schedule: VestingSchedule, /// Amount already claimed by beneficiary. pub claimed_amount: u64, /// Current state. pub state: VestingState, /// Timestamp when revoked (if applicable). pub revoked_at: Option, /// Description/purpose of this vesting. pub description: String, } /// Vesting errors. #[derive(Debug, Error)] pub enum VestingError { #[error("Vesting contract not found")] NotFound, #[error("Not authorized to perform this action")] Unauthorized, #[error("Nothing available to claim")] NothingToClaim, #[error("Vesting is not revocable")] NotRevocable, #[error("Vesting already revoked")] AlreadyRevoked, #[error("Vesting already completed")] AlreadyCompleted, #[error("Invalid vesting parameters")] InvalidParameters, #[error("Cliff not yet passed")] CliffNotPassed, #[error("Insufficient balance")] InsufficientBalance, } impl VestingContract { /// Creates a new vesting contract. pub fn new( beneficiary: Address, grantor: Address, schedule: VestingSchedule, description: String, ) -> Result { // Validate parameters if schedule.total_amount == 0 { return Err(VestingError::InvalidParameters); } if schedule.vesting_duration == 0 { return Err(VestingError::InvalidParameters); } if schedule.cliff_duration > schedule.vesting_duration { return Err(VestingError::InvalidParameters); } // Generate unique ID let mut id_data = Vec::new(); id_data.extend_from_slice(beneficiary.payload()); id_data.extend_from_slice(grantor.payload()); id_data.extend_from_slice(&schedule.start_time.to_le_bytes()); id_data.extend_from_slice(&schedule.total_amount.to_le_bytes()); let id = Hash256::blake3(&id_data); Ok(VestingContract { id, beneficiary, grantor, schedule, claimed_amount: 0, state: VestingState::Active, revoked_at: None, description, }) } /// Creates a vesting contract for a team member. pub fn for_team_member( beneficiary: Address, grantor: Address, amount: u64, start_time: u64, name: &str, ) -> Result { Self::new( beneficiary, grantor, VestingSchedule::standard_4year(amount, start_time), format!("Team allocation for {}", name), ) } /// Creates a vesting contract for an advisor. pub fn for_advisor( beneficiary: Address, grantor: Address, amount: u64, start_time: u64, name: &str, ) -> Result { Self::new( beneficiary, grantor, VestingSchedule::advisor(amount, start_time), format!("Advisor allocation for {}", name), ) } /// Creates a vesting contract for a founder. pub fn for_founder( beneficiary: Address, amount: u64, start_time: u64, ) -> Result { Self::new( beneficiary.clone(), beneficiary, // Founder is their own grantor VestingSchedule::founder(amount, start_time), "Founder allocation".to_string(), ) } /// Returns the total vested amount at current time. pub fn vested_amount(&self, timestamp: u64) -> u64 { match self.state { VestingState::Active => self.schedule.vested_at(timestamp), VestingState::Revoked => { // If revoked, vested amount is frozen at revocation time if let Some(revoked_at) = self.revoked_at { self.schedule.vested_at(revoked_at) } else { 0 } } VestingState::Completed => self.schedule.total_amount, } } /// Returns the claimable amount (vested - already claimed). pub fn claimable_amount(&self, timestamp: u64) -> u64 { let vested = self.vested_amount(timestamp); vested.saturating_sub(self.claimed_amount) } /// Returns the unvested amount. pub fn unvested_amount(&self, timestamp: u64) -> u64 { self.schedule .total_amount .saturating_sub(self.vested_amount(timestamp)) } /// Claims available tokens. pub fn claim(&mut self, caller: &Address, timestamp: u64) -> Result { // Only beneficiary can claim if caller != &self.beneficiary { return Err(VestingError::Unauthorized); } // Check state if self.state == VestingState::Completed { return Err(VestingError::AlreadyCompleted); } // Calculate claimable let claimable = self.claimable_amount(timestamp); if claimable == 0 { return Err(VestingError::NothingToClaim); } // Update claimed amount self.claimed_amount += claimable; // Check if completed if self.claimed_amount >= self.schedule.total_amount { self.state = VestingState::Completed; } Ok(claimable) } /// Revokes the vesting contract (only by grantor, if revocable). pub fn revoke(&mut self, caller: &Address, timestamp: u64) -> Result { // Only grantor can revoke if caller != &self.grantor { return Err(VestingError::Unauthorized); } // Check if revocable if !self.schedule.revocable { return Err(VestingError::NotRevocable); } // Check state if self.state == VestingState::Revoked { return Err(VestingError::AlreadyRevoked); } if self.state == VestingState::Completed { return Err(VestingError::AlreadyCompleted); } // Calculate unvested amount to return to grantor let unvested = self.unvested_amount(timestamp); // Update state self.state = VestingState::Revoked; self.revoked_at = Some(timestamp); Ok(unvested) } /// Returns vesting progress as a percentage (0-100). pub fn progress_percent(&self, timestamp: u64) -> f64 { let vested = self.vested_amount(timestamp); (vested as f64 / self.schedule.total_amount as f64) * 100.0 } /// Returns summary information. pub fn summary(&self, timestamp: u64) -> VestingSummary { VestingSummary { id: self.id, beneficiary: self.beneficiary.clone(), total: self.schedule.total_amount, vested: self.vested_amount(timestamp), claimed: self.claimed_amount, claimable: self.claimable_amount(timestamp), unvested: self.unvested_amount(timestamp), state: self.state, cliff_passed: self.schedule.cliff_passed(timestamp), fully_vested: self.schedule.fully_vested(timestamp), progress_percent: self.progress_percent(timestamp), } } } /// Summary of a vesting contract's current state. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VestingSummary { pub id: Hash256, pub beneficiary: Address, pub total: u64, pub vested: u64, pub claimed: u64, pub claimable: u64, pub unvested: u64, pub state: VestingState, pub cliff_passed: bool, pub fully_vested: bool, pub progress_percent: f64, } /// Manages all vesting contracts. pub struct VestingManager { contracts: hashbrown::HashMap, by_beneficiary: hashbrown::HashMap>, by_grantor: hashbrown::HashMap>, } impl VestingManager { /// Creates a new vesting manager. pub fn new() -> Self { VestingManager { contracts: hashbrown::HashMap::new(), by_beneficiary: hashbrown::HashMap::new(), by_grantor: hashbrown::HashMap::new(), } } /// Creates a new vesting contract. pub fn create_vesting( &mut self, beneficiary: Address, grantor: Address, schedule: VestingSchedule, description: String, ) -> Result { let contract = VestingContract::new(beneficiary.clone(), grantor.clone(), schedule, description)?; let id = contract.id; // Index by beneficiary self.by_beneficiary.entry(beneficiary).or_default().push(id); // Index by grantor self.by_grantor.entry(grantor).or_default().push(id); self.contracts.insert(id, contract); Ok(id) } /// Gets a vesting contract by ID. pub fn get(&self, id: &Hash256) -> Option<&VestingContract> { self.contracts.get(id) } /// Gets mutable reference to a vesting contract. pub fn get_mut(&mut self, id: &Hash256) -> Option<&mut VestingContract> { self.contracts.get_mut(id) } /// Gets all vesting contracts for a beneficiary. pub fn get_by_beneficiary(&self, beneficiary: &Address) -> Vec<&VestingContract> { self.by_beneficiary .get(beneficiary) .map(|ids| ids.iter().filter_map(|id| self.contracts.get(id)).collect()) .unwrap_or_default() } /// Gets all vesting contracts created by a grantor. pub fn get_by_grantor(&self, grantor: &Address) -> Vec<&VestingContract> { self.by_grantor .get(grantor) .map(|ids| ids.iter().filter_map(|id| self.contracts.get(id)).collect()) .unwrap_or_default() } /// Claims tokens from a vesting contract. pub fn claim( &mut self, id: &Hash256, caller: &Address, timestamp: u64, ) -> Result { let contract = self.contracts.get_mut(id).ok_or(VestingError::NotFound)?; contract.claim(caller, timestamp) } /// Revokes a vesting contract. pub fn revoke( &mut self, id: &Hash256, caller: &Address, timestamp: u64, ) -> Result { let contract = self.contracts.get_mut(id).ok_or(VestingError::NotFound)?; contract.revoke(caller, timestamp) } /// Returns total vested amount across all contracts for a beneficiary. pub fn total_vested_for(&self, beneficiary: &Address, timestamp: u64) -> u64 { self.get_by_beneficiary(beneficiary) .iter() .map(|c| c.vested_amount(timestamp)) .sum() } /// Returns total claimable amount across all contracts for a beneficiary. pub fn total_claimable_for(&self, beneficiary: &Address, timestamp: u64) -> u64 { self.get_by_beneficiary(beneficiary) .iter() .map(|c| c.claimable_amount(timestamp)) .sum() } /// Returns all active contracts. pub fn active_contracts(&self) -> Vec<&VestingContract> { self.contracts .values() .filter(|c| c.state == VestingState::Active) .collect() } /// Returns statistics. pub fn stats(&self) -> VestingStats { let contracts: Vec<_> = self.contracts.values().collect(); VestingStats { total_contracts: contracts.len(), active_contracts: contracts .iter() .filter(|c| c.state == VestingState::Active) .count(), revoked_contracts: contracts .iter() .filter(|c| c.state == VestingState::Revoked) .count(), completed_contracts: contracts .iter() .filter(|c| c.state == VestingState::Completed) .count(), total_amount: contracts.iter().map(|c| c.schedule.total_amount).sum(), total_claimed: contracts.iter().map(|c| c.claimed_amount).sum(), } } } impl Default for VestingManager { fn default() -> Self { Self::new() } } /// Vesting statistics. #[derive(Clone, Debug)] pub struct VestingStats { pub total_contracts: usize, pub active_contracts: usize, pub revoked_contracts: usize, pub completed_contracts: usize, pub total_amount: u64, pub total_claimed: u64, } #[cfg(test)] mod tests { use super::*; fn test_address(n: u8) -> Address { let mut bytes = [0u8; 32]; bytes[0] = n; Address::from_parts( synor_types::Network::Devnet, synor_types::address::AddressType::P2PKH, bytes, ) } #[test] fn test_vesting_schedule_cliff() { let schedule = VestingSchedule::standard_4year(1_000_000, 0); // Implementation uses 30-day months, so 12 months = 360 days let seconds_per_month: u64 = 30 * 24 * 60 * 60; let cliff_12_months = 12 * seconds_per_month; // 31104000 let vesting_48_months = 48 * seconds_per_month; // 124416000 // Before cliff assert_eq!(schedule.vested_at(0), 0); assert_eq!(schedule.vested_at(6 * seconds_per_month), 0); // 6 months assert_eq!(schedule.vested_at(cliff_12_months - 1), 0); // Just before cliff // At cliff - some tokens should be vested (linear from 0) assert!(schedule.vested_at(cliff_12_months) > 0); // After full vesting (48 months) assert_eq!(schedule.vested_at(vesting_48_months), 1_000_000); } #[test] fn test_vesting_contract_claim() { let beneficiary = test_address(1); let grantor = test_address(2); let start_time = 0; let mut contract = VestingContract::new( beneficiary.clone(), grantor, VestingSchedule::new(1_000_000, 0, 12, start_time, true), // 1 year, no cliff "Test vesting".to_string(), ) .unwrap(); // After 6 months, should have ~50% vested let six_months = 6 * 30 * 24 * 60 * 60; let claimable = contract.claimable_amount(six_months); assert!(claimable > 400_000 && claimable < 600_000); // Claim let claimed = contract.claim(&beneficiary, six_months).unwrap(); assert_eq!(claimed, claimable); assert_eq!(contract.claimed_amount, claimable); // No more claimable immediately assert_eq!(contract.claimable_amount(six_months), 0); } #[test] fn test_vesting_revocation() { let beneficiary = test_address(1); let grantor = test_address(2); let mut contract = VestingContract::new( beneficiary, grantor.clone(), VestingSchedule::new(1_000_000, 0, 12, 0, true), "Revocable".to_string(), ) .unwrap(); // Revoke at 6 months let six_months = 6 * 30 * 24 * 60 * 60; let unvested = contract.revoke(&grantor, six_months).unwrap(); assert!(unvested > 400_000); // ~50% unvested returned assert_eq!(contract.state, VestingState::Revoked); } #[test] fn test_non_revocable() { let beneficiary = test_address(1); let grantor = test_address(2); let mut contract = VestingContract::new( beneficiary, grantor.clone(), VestingSchedule::new(1_000_000, 0, 12, 0, false), // non-revocable "Non-revocable".to_string(), ) .unwrap(); let result = contract.revoke(&grantor, 0); assert!(matches!(result, Err(VestingError::NotRevocable))); } #[test] fn test_vesting_manager() { let mut manager = VestingManager::new(); let beneficiary = test_address(1); let grantor = test_address(2); let id = manager .create_vesting( beneficiary.clone(), grantor, VestingSchedule::new(1_000_000, 0, 12, 0, true), "Test".to_string(), ) .unwrap(); assert!(manager.get(&id).is_some()); assert_eq!(manager.get_by_beneficiary(&beneficiary).len(), 1); let stats = manager.stats(); assert_eq!(stats.total_contracts, 1); assert_eq!(stats.active_contracts, 1); } }