synor/crates/synor-governance/src/vesting.rs
2026-01-08 06:23:23 +05:30

684 lines
21 KiB
Rust

//! 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<u64>,
/// 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<Self, VestingError> {
// 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, VestingError> {
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, VestingError> {
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, VestingError> {
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<u64, VestingError> {
// 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<u64, VestingError> {
// 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<Hash256, VestingContract>,
by_beneficiary: hashbrown::HashMap<Address, Vec<Hash256>>,
by_grantor: hashbrown::HashMap<Address, Vec<Hash256>>,
}
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<Hash256, VestingError> {
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<u64, VestingError> {
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<u64, VestingError> {
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);
}
}