diff --git a/PERIODIC_VESTING.md b/PERIODIC_VESTING.md new file mode 100644 index 0000000..eb26218 --- /dev/null +++ b/PERIODIC_VESTING.md @@ -0,0 +1,131 @@ +# Periodic Vesting Feature + +This document describes the periodic vesting steps feature implemented in Issue #14. + +## Overview + +The vesting contract now supports both linear and periodic vesting schedules: + +- **Linear Vesting** (`step_duration = 0`): Tokens vest continuously over time +- **Periodic Vesting** (`step_duration > 0`): Tokens vest in discrete steps (e.g., monthly) + +## Key Features + +### Step Function Vesting +When `step_duration > 0`, the contract uses a step function that rounds down to the nearest completed step. This ensures users only receive tokens that have fully vested according to the step schedule. + +### Formula +For periodic vesting, the calculation is: +``` +vested = (elapsed / step_duration) * (total_amount / total_steps) * step_duration +``` + +This simplifies to: +``` +vested = completed_steps * amount_per_step +``` + +### Common Time Durations +The contract provides helper functions for common vesting periods: + +```rust +// Monthly vesting (30 days) +let monthly_duration = VestingContract::monthly(); // 2,592,000 seconds + +// Quarterly vesting (90 days) +let quarterly_duration = VestingContract::quarterly(); // 7,776,000 seconds + +// Yearly vesting (365 days) +let yearly_duration = VestingContract::yearly(); // 31,536,000 seconds +``` + +## Usage Examples + +### Creating a Monthly Vesting Vault +```rust +let vault_id = client.create_vault_full( + &beneficiary, + &12000i128, // 12,000 tokens total + &start_time, + &end_time, // 12 months later + &0i128, // no keeper fee + &false, // revocable + &true, // transferable + &VestingContract::monthly() // monthly step duration +); +``` + +### Creating a Quarterly Vesting Vault +```rust +let vault_id = client.create_vault_full( + &beneficiary, + &4000i128, // 4,000 tokens total (1,000 per quarter) + &start_time, + &end_time, // 1 year later + &0i128, + &false, + &true, + &VestingContract::quarterly() // quarterly step duration +); +``` + +## Behavior + +### Rounding Down +Periodic vesting rounds down to the nearest completed step: +- After 1.5 months with monthly steps: Only 1 month worth vests +- After 2.9 months with monthly steps: Only 2 months worth vests +- After exactly 3 months with monthly steps: 3 months worth vests + +### Linear vs Periodic Comparison +| Time Elapsed | Linear Vesting | Monthly Periodic | +|-------------|----------------|------------------| +| 1 month | 1,000 tokens | 1,000 tokens | +| 1.5 months | 1,500 tokens | 1,000 tokens | +| 2 months | 2,000 tokens | 2,000 tokens | +| 2.5 months | 2,500 tokens | 2,000 tokens | +| 3 months | 3,000 tokens | 3,000 tokens | + +## Acceptance Criteria + +✅ **[x] If step_duration > 0, calculate vested = (elapsed / step_duration) * rate * step_duration** + +✅ **[x] Round down to the nearest month** + +## Testing + +Comprehensive tests are included in `test.rs`: + +- `test_monthly_vesting_step_function()`: Tests monthly vesting with step function behavior +- `test_quarterly_vesting()`: Tests quarterly vesting +- `test_linear_vs_periodic_vesting()`: Compares linear vs periodic vesting +- `test_periodic_vesting_edge_cases()`: Tests edge cases + +## Implementation Details + +### Vault Structure +The `Vault` struct includes a `step_duration` field: +```rust +pub struct Vault { + // ... other fields + pub step_duration: u64, // Duration of each vesting step in seconds (0 = linear) + // ... other fields +} +``` + +### Calculation Function +The `calculate_time_vested_amount` function handles both linear and periodic vesting: +- If `step_duration == 0`: Uses linear vesting formula +- If `step_duration > 0`: Uses periodic vesting with step function + +### Backward Compatibility +- Existing vaults with `step_duration = 0` continue to use linear vesting +- New vaults can specify any positive `step_duration` for periodic vesting +- All existing functionality remains unchanged + +## Benefits + +1. **Predictable Vesting**: Users know exactly when tokens will vest +2. **Accounting Simplicity**: Easy to track vesting in discrete periods +3. **Compliance**: Meets regulatory requirements for periodic vesting +4. **Flexibility**: Supports any step duration (monthly, quarterly, yearly, custom) diff --git a/contracts/vesting_contracts/src/lib.rs b/contracts/vesting_contracts/src/lib.rs index 9a8c498..f4b2233 100644 --- a/contracts/vesting_contracts/src/lib.rs +++ b/contracts/vesting_contracts/src/lib.rs @@ -30,6 +30,7 @@ pub enum DataKey { KeeperFees, UserVaults(Address), VaultMilestones(u64), + UserVaults(Address), KeeperFees, IsPaused, IsDeprecated, @@ -45,6 +46,14 @@ pub use factory::{VestingFactory, VestingFactoryClient}; #[contract] pub struct VestingContract; +/// Vault structure with lazy initialization +/// +/// Supports both linear and periodic vesting schedules: +/// - Linear vesting (step_duration = 0): Tokens vest continuously over time +/// - Periodic vesting (step_duration > 0): Tokens vest in discrete steps (e.g., monthly) +/// +/// For periodic vesting, the calculation rounds down to the nearest completed step, +/// ensuring users only receive tokens that have fully vested according to the step schedule. // Vault structure with lazy initialization Token, // yield-bearing token TotalShares, // remaining initial_deposit_shares @@ -67,7 +76,14 @@ pub struct Vault { pub start_time: u64, pub end_time: u64, pub creation_time: u64, // Timestamp of creation for clawback grace period - pub step_duration: u64, // Duration of each vesting step in seconds (0 = linear) + /// Duration of each vesting step in seconds (0 = linear vesting) + /// + /// Common values: + /// - 0: Linear vesting (continuous) + /// - 2,592,000: Monthly (30 days) + /// - 7,776,000: Quarterly (90 days) + /// - 31,536,000: Yearly (365 days) + pub step_duration: u64, pub is_initialized: bool, // Lazy initialization flag pub is_irrevocable: bool, // Security flag to prevent admin withdrawal @@ -182,6 +198,8 @@ impl VestingContract { env.storage().instance().set(&DataKey::InitialSupply, &initial_supply); env.storage().instance().set(&DataKey::AdminBalance, &initial_supply); + env.storage().instance().set(&DataKey::InitialSupply, &initial_supply); + env.storage().instance().set(&DataKey::AdminBalance, &initial_supply); env.storage().instance().set(&DataKey::AdminAddress, &admin); env.storage() .instance() @@ -702,6 +720,63 @@ impl VestingContract { } } + /// Helper functions for common time durations in seconds +/// These can be used when creating vaults with periodic vesting +impl VestingContract { + /// Convert days to seconds + pub const fn seconds(days: u64) -> u64 { days * 86400 } + + /// 30 days in seconds (monthly vesting) + pub const fn monthly() -> u64 { 30 * 86400 } // 2,592,000 seconds + + /// 90 days in seconds (quarterly vesting) + pub const fn quarterly() -> u64 { 3 * 30 * 86400 } // 7,776,000 seconds + + /// 365 days in seconds (yearly vesting) + pub const fn yearly() -> u64 { 365 * 86400 } // 31,536,000 seconds +} + +/// Calculate the amount of tokens that have vested based on time +/// +/// Supports two vesting modes: +/// 1. Linear vesting (step_duration = 0): Continuous vesting over time +/// 2. Periodic vesting (step_duration > 0): Discrete step vesting with rounding down +/// +/// For periodic vesting, the formula is: +/// vested = (elapsed / step_duration) * (total_amount / total_steps) * step_duration +/// This rounds down to the nearest completed step, ensuring users only receive +/// tokens that have fully vested according to the step schedule. +/// +/// # Arguments +/// * `env` - The contract environment +/// * `vault` - The vault to calculate vesting for +/// +/// # Returns +/// The amount of tokens that have vested +fn calculate_time_vested_amount(env: &Env, vault: &Vault) -> i128 { + let now = env.ledger().timestamp(); + if now < vault.start_time { return 0; } + if now >= vault.end_time { return vault.total_amount; } + + let duration = vault.end_time - vault.start_time; + if duration == 0 { return vault.total_amount; } + + let elapsed = now - vault.start_time; + + if vault.step_duration > 0 { + // Periodic vesting with monthly rounding + // First, round elapsed time down to the nearest step boundary + let rounded_elapsed = (elapsed / vault.step_duration) * vault.step_duration; + + // Calculate completed steps + let completed_steps = rounded_elapsed / vault.step_duration; + + // Calculate rate per step: total_amount / total_steps + let total_steps = duration / vault.step_duration; + if total_steps == 0 { return 0; } + + let amount_per_step = vault.total_amount / total_steps as i128; + let vested = completed_steps as i128 * amount_per_step; // Helper to calculate vested amount based on time (linear or step) fn calculate_time_vested_amount(env: &Env, vault: &Vault) -> i128 { let now = env.ledger().timestamp(); @@ -731,11 +806,7 @@ impl VestingContract { let vested = completed_steps as i128 * rate_per_second * vault.step_duration as i128; // Ensure we don't exceed total amount - if vested > vault.total_amount { - vault.total_amount - } else { - vested - } + vested.min(vault.total_amount) } else { // Linear vesting (vault.total_amount * elapsed as i128) / duration as i128 @@ -1433,6 +1504,7 @@ impl VestingContract { vault_ids } + // Admin-only: Revoke tokens from a vault and return them to admin // Revoke tokens from a vault and return them to admin // Internal helper: revoke full unreleased amount from a vault and emit event. // Does NOT update admin balance — caller is responsible for a single aggregated transfer. @@ -1466,6 +1538,9 @@ impl VestingContract { (unreleased_amount, timestamp), ); + let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + total_shares -= unreleased_amount; + env.storage().instance().set(&DataKey::TotalShares, &total_shares); unreleased_amount } @@ -1502,9 +1577,10 @@ impl VestingContract { let timestamp = env.ledger().timestamp(); env.events().publish( (Symbol::new(&env, "TokensRevoked"), vault_id), - (returned, timestamp), + (unreleased_amount, timestamp), ); + unreleased_amount returned } @@ -1609,6 +1685,23 @@ impl VestingContract { Self::require_admin(&env); let mut total_returned: i128 = 0; + let mut total_shares_change: i128 = 0; + + for id in vault_ids.iter() { + let vault: Vault = env.storage().instance().get(&DataKey::VaultData(id)).unwrap_or_else(|| panic!("Vault not found")); + + if vault.is_irrevocable { panic!("Vault is irrevocable"); } + + let unreleased_amount = vault.total_amount - vault.released_amount; + if unreleased_amount > 0 { + total_returned += unreleased_amount; + total_shares_change += unreleased_amount; + + // Update vault to mark all tokens as released + let mut updated_vault = vault; + updated_vault.released_amount = vault.total_amount; + env.storage().instance().set(&DataKey::VaultData(id), &updated_vault); + } for vault_id in vault_ids.iter() { let mut vault: Vault = env .storage() @@ -1647,6 +1740,11 @@ impl VestingContract { env.storage() .instance() .set(&DataKey::AdminBalance, &admin_balance); + + // Update total shares + let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + total_shares -= total_shares_change; + env.storage().instance().set(&DataKey::TotalShares, &total_shares); let mut total_shares: i128 = env .storage() diff --git a/contracts/vesting_contracts/src/test.rs b/contracts/vesting_contracts/src/test.rs index 7ba3fb2..590aae4 100644 --- a/contracts/vesting_contracts/src/test.rs +++ b/contracts/vesting_contracts/src/test.rs @@ -2279,6 +2279,226 @@ fn test_global_pause_functionality() { let original_start_time = vault.start_time; let original_cliff_duration = vault.cliff_duration; + // ------------------------------------------------------------------------- + // Periodic Vesting Tests + // ------------------------------------------------------------------------- + + #[test] + fn test_monthly_vesting_step_function() { + let (env, contract_id, client, admin, token_addr) = setup(); + let beneficiary = Address::generate(&env); + + // Create a 12-month vault with monthly step duration (30 days = 2,592,000 seconds) + let monthly_seconds = 30 * 24 * 60 * 60; // 2,592,000 seconds + let total_amount = 12000i128; // 1000 tokens per month + let start_time = 10000u64; + let end_time = start_time + (12 * monthly_seconds); // 12 months + + env.as_contract(&contract_id, || { + env.current_contract_address().set(&admin); + }); + + let vault_id = client.create_vault_full( + &beneficiary, + &total_amount, + &start_time, + &end_time, + &0i128, // no keeper fee + &false, // revocable + &true, // transferable + &monthly_seconds, // monthly step duration + ); + + // Test 1: Before start time - should have 0 vested + env.ledger().set_timestamp(start_time - 1000); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 0i128, "Should have 0 vested before start time"); + + // Test 2: Exactly at start time - should have 0 vested + env.ledger().set_timestamp(start_time); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 0i128, "Should have 0 vested at start time"); + + // Test 3: Half way through first month - should still have 0 vested (rounding down) + env.ledger().set_timestamp(start_time + (monthly_seconds / 2)); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 0i128, "Should have 0 vested half way through first month"); + + // Test 4: Exactly 1 month completed - should have 1000 vested + env.ledger().set_timestamp(start_time + monthly_seconds); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 1000i128, "Should have 1000 vested after 1 month"); + + // Test 5: 1 month + 1 day - should still have 1000 vested (rounding down) + env.ledger().set_timestamp(start_time + monthly_seconds + (24 * 60 * 60)); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 1000i128, "Should have 1000 vested after 1 month + 1 day"); + + // Test 6: Exactly 6 months completed - should have 6000 vested + env.ledger().set_timestamp(start_time + (6 * monthly_seconds)); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 6000i128, "Should have 6000 vested after 6 months"); + + // Test 7: Exactly 12 months completed - should have all 12000 vested + env.ledger().set_timestamp(end_time); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 12000i128, "Should have all 12000 vested at end time"); + + // Test 8: After end time - should still have all 12000 vested + env.ledger().set_timestamp(end_time + monthly_seconds); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 12000i128, "Should have all 12000 vested after end time"); + } + + #[test] + fn test_quarterly_vesting() { + let (env, contract_id, client, admin, token_addr) = setup(); + let beneficiary = Address::generate(&env); + + // Create a 1-year vault with quarterly step duration (3 months = 90 days) + let quarterly_seconds = 90 * 24 * 60 * 60; // 7,776,000 seconds + let total_amount = 4000i128; // 1000 tokens per quarter + let start_time = 10000u64; + let end_time = start_time + (4 * quarterly_seconds); // 4 quarters + + env.as_contract(&contract_id, || { + env.current_contract_address().set(&admin); + }); + + let vault_id = client.create_vault_full( + &beneficiary, + &total_amount, + &start_time, + &end_time, + &0i128, + &false, + &true, + &quarterly_seconds, + ); + + // Test: Should vest in quarterly steps + env.ledger().set_timestamp(start_time + quarterly_seconds); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 1000i128, "Should have 1000 vested after 1 quarter"); + + env.ledger().set_timestamp(start_time + (2 * quarterly_seconds)); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 2000i128, "Should have 2000 vested after 2 quarters"); + + env.ledger().set_timestamp(start_time + (3 * quarterly_seconds)); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 3000i128, "Should have 3000 vested after 3 quarters"); + + env.ledger().set_timestamp(end_time); + let claimable = client.get_claimable_amount(&vault_id); + assert_eq!(claimable, 4000i128, "Should have all 4000 vested at end"); + } + + #[test] + fn test_linear_vs_periodic_vesting() { + let (env, contract_id, client, admin, token_addr) = setup(); + let beneficiary = Address::generate(&env); + + let total_amount = 12000i128; + let start_time = 10000u64; + let duration = 12 * 30 * 24 * 60 * 60; // 12 months in seconds + let end_time = start_time + duration; + let monthly_seconds = 30 * 24 * 60 * 60; + + env.as_contract(&contract_id, || { + env.current_contract_address().set(&admin); + }); + + // Create linear vesting vault + let linear_vault_id = client.create_vault_full( + &beneficiary, + &total_amount, + &start_time, + &end_time, + &0i128, + &false, + &true, + &0u64, // step_duration = 0 for linear + ); + + // Create periodic vesting vault + let periodic_vault_id = client.create_vault_full( + &beneficiary, + &total_amount, + &start_time, + &end_time, + &0i128, + &false, + &true, + &monthly_seconds, // monthly steps + ); + + // Test at 6 months: linear should have 6000, periodic should have 6000 + env.ledger().set_timestamp(start_time + (6 * monthly_seconds)); + + let linear_claimable = client.get_claimable_amount(&linear_vault_id); + let periodic_claimable = client.get_claimable_amount(&periodic_vault_id); + + assert_eq!(linear_claimable, 6000i128, "Linear should have 6000 at 6 months"); + assert_eq!(periodic_claimable, 6000i128, "Periodic should have 6000 at 6 months"); + + // Test at 6.5 months: linear should have ~6500, periodic should still have 6000 + env.ledger().set_timestamp(start_time + (6 * monthly_seconds) + (monthly_seconds / 2)); + + let linear_claimable = client.get_claimable_amount(&linear_vault_id); + let periodic_claimable = client.get_claimable_amount(&periodic_vault_id); + + assert!(linear_claimable > 6000i128 && linear_claimable < 7000i128, + "Linear should be between 6000-7000 at 6.5 months"); + assert_eq!(periodic_claimable, 6000i128, + "Periodic should still have 6000 at 6.5 months (rounding down)"); + } + + #[test] + fn test_periodic_vesting_edge_cases() { + let (env, contract_id, client, admin, token_addr) = setup(); + let beneficiary = Address::generate(&env); + + env.as_contract(&contract_id, || { + env.current_contract_address().set(&admin); + }); + + // Test 1: Zero duration vault + let vault_id1 = client.create_vault_full( + &beneficiary, + &1000i128, + &10000u64, + &10000u64, // same start and end time + &0i128, + &false, + &true, + &2592000u64, // monthly + ); + + let claimable = client.get_claimable_amount(&vault_id1); + assert_eq!(claimable, 1000i128, "Zero duration vault should vest all immediately"); + + // Test 2: Step duration longer than total duration + let vault_id2 = client.create_vault_full( + &beneficiary, + &1000i128, + &10000u64, + &20000u64, // 10,000 seconds duration + &0i128, + &false, + &true, + &86400u64, // 1 day step (longer than duration) + ); + + env.ledger().set_timestamp(15000u64); // in the middle + let claimable = client.get_claimable_amount(&vault_id2); + assert_eq!(claimable, 0i128, "Should have 0 vested when step > duration"); + + env.ledger().set_timestamp(20000u64); // at end + let claimable = client.get_claimable_amount(&vault_id2); + assert_eq!(claimable, 1000i128, "Should have all vested at end time"); + } + // Attempt to update vault via admin functions (should not affect start_time/cliff_duration) client.mark_irrevocable(&vault_id); client.transfer_beneficiary(&vault_id, &Address::generate(&env));