diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 8e85da00..6129fcd7 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -993,6 +993,28 @@ pub fn keys_claimed_topics(creator: &Address, beneficiary: &Address) -> (Symbol, ) } +pub const VESTING_CLIFF_CLAIMED_EVENT_NAME: Symbol = symbol_short!("vc_claim"); + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct VestingCliffClaimedEvent { + pub creator_id: Address, + pub beneficiary: Address, + pub amount: i128, + pub ledger: u32, +} + +pub fn vesting_cliff_claimed_topics( + creator: &Address, + beneficiary: &Address, +) -> (Symbol, Address, Address) { + ( + VESTING_CLIFF_CLAIMED_EVENT_NAME, + creator.clone(), + beneficiary.clone(), + ) +} + // --- Timelock events --- /// Event name for config change proposed. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index edbfee66..8422f3b0 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] #![allow(clippy::enum_variant_names)] // `contracttype` macro-generated enums share prefixes by design pub mod quote_view_errors; - +pub mod vesting; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec, }; @@ -786,6 +786,10 @@ pub mod constants { DataKey::VestingSchedule(creator.clone(), beneficiary.clone()) } + pub fn vesting_cliff_config(creator: &Address, beneficiary: &Address) -> DataKey { + DataKey::VestingCliffConfig(creator.clone(), beneficiary.clone()) + } + pub const CIRCUIT_BREAKER_THRESHOLD: DataKey = DataKey::CircuitBreakerThreshold; pub fn referral_earnings(referrer: &Address) -> DataKey { @@ -1800,6 +1804,8 @@ pub enum DataKey { /// gated access (Issue #953). Absent means access gating is not configured /// for that creator and `subscribe` rejects. MinHoldForAccess(Address), + /// (creator, beneficiary) -> cliff-vesting config (issue #916). + VestingCliffConfig(Address, Address), } #[derive(Clone, Debug, PartialEq)] @@ -11745,7 +11751,109 @@ impl CreatorKeysContract { Ok(claimable) } + #[allow(clippy::too_many_arguments)] + pub fn create_vesting_cliff( + env: Env, + creator: Address, + caller: Address, + beneficiary: Address, + total_allocation: i128, + cliff_timestamp: u64, + start_timestamp: u64, + duration_secs: u64, + ) -> Result<(), ContractError> { + caller.require_auth(); + if caller != creator { + return Err(ContractError::Unauthorized); + } + if total_allocation <= 0 { + return Err(ContractError::NotPositiveAmount); + } + let key = constants::storage::vesting_cliff_config(&creator, &beneficiary); + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + let config = vesting::VestingConfig { + total_allocation, + cliff_timestamp, + start_timestamp, + duration_secs, + claimed_amount: 0, + }; + env.storage().persistent().set(&key, &config); + extend_key_ttl_to_full_window(&env, &key); + Ok(()) + } + + pub fn claim_vested_cliff( + env: Env, + creator: Address, + beneficiary: Address, + ) -> Result { + beneficiary.require_auth(); + let key = constants::storage::vesting_cliff_config(&creator, &beneficiary); + let mut config: vesting::VestingConfig = env + .storage() + .persistent() + .get(&key) + .ok_or(ContractError::NotRegistered)?; + let now = env.ledger().timestamp(); + let claimable = vesting::claimable_amount(&config, now); + if claimable == 0 { + return Err(ContractError::NoDividendClaimable); + } + config.claimed_amount = config + .claimed_amount + .checked_add(claimable) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&key, &config); + extend_key_ttl_to_full_window(&env, &key); + + let balance_key = constants::storage::holder_balance_key(&creator, &beneficiary); + let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + let claimable_u32 = u32::try_from(claimable).map_err(|_| ContractError::Overflow)?; + let new_balance = current_balance + .checked_add(claimable_u32) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&balance_key, &new_balance); + extend_key_ttl_to_full_window(&env, &balance_key); + + if current_balance == 0 { + let mut profile = read_registered_creator_profile(&env, &creator)?; + profile.holder_count = profile + .holder_count + .checked_add(1) + .ok_or(ContractError::Overflow)?; + let profile_key = constants::storage::creator(&creator); + env.storage().persistent().set(&profile_key, &profile); + } + + env.events().publish( + events::vesting_cliff_claimed_topics(&creator, &beneficiary), + events::VestingCliffClaimedEvent { + creator_id: creator, + beneficiary, + amount: claimable, + ledger: env.ledger().sequence(), + }, + ); + Ok(claimable) + } + pub fn get_vesting_info( + env: Env, + creator: Address, + beneficiary: Address, + ) -> Result { + let key = constants::storage::vesting_cliff_config(&creator, &beneficiary); + let config: vesting::VestingConfig = env + .storage() + .persistent() + .get(&key) + .ok_or(ContractError::NotRegistered)?; + let now = env.ledger().timestamp(); + Ok(vesting::get_vesting_info(&config, now)) + } pub fn set_circuit_breaker_threshold( env: Env, admin: Address, diff --git a/creator-keys/src/vesting.rs b/creator-keys/src/vesting.rs new file mode 100644 index 00000000..b26c1f00 --- /dev/null +++ b/creator-keys/src/vesting.rs @@ -0,0 +1,204 @@ +//! Linear vesting release calculation with cliff enforcement (issue #916). +//! +//! Pure module — no `Env`, no storage — so the release math is unit-testable +//! in isolation, mirroring the `fee` module's `compute_fee_split` approach. + +use soroban_sdk::contracttype; + +/// Persisted vesting configuration for a single beneficiary. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct VestingConfig { + pub total_allocation: i128, + pub cliff_timestamp: u64, + pub start_timestamp: u64, + pub duration_secs: u64, + pub claimed_amount: i128, +} + +/// Aggregated read-only view returned by `get_vesting_info`. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct VestingInfo { + pub total_allocation: i128, + pub vested_amount: i128, + pub claimed_amount: i128, + pub claimable_amount: i128, + pub cliff_timestamp: u64, + pub start_timestamp: u64, + pub duration_secs: u64, + pub is_fully_vested: bool, +} + +/// Computes total unlocked so far (ignoring what's already claimed). +/// +/// - `0` before `cliff_timestamp`. +/// - Linear from `start_timestamp` once past the cliff. +/// - Full allocation once `elapsed >= duration_secs`. +/// - `duration_secs == 0` fully vests exactly at the cliff. +pub fn vested_amount(config: &VestingConfig, current_timestamp: u64) -> i128 { + if config.total_allocation <= 0 { + return 0; + } + if current_timestamp < config.cliff_timestamp { + return 0; + } + if config.duration_secs == 0 { + return config.total_allocation; + } + + let elapsed = current_timestamp.saturating_sub(config.start_timestamp); + if elapsed >= config.duration_secs { + return config.total_allocation; + } + + (config.total_allocation.saturating_mul(i128::from(elapsed))) / i128::from(config.duration_secs) +} + +/// Vested minus already claimed, floored at zero. +pub fn claimable_amount(config: &VestingConfig, current_timestamp: u64) -> i128 { + vested_amount(config, current_timestamp) + .saturating_sub(config.claimed_amount) + .max(0) +} + +/// Returns all vesting state in one call (issue #916 requirement). +pub fn get_vesting_info(config: &VestingConfig, current_timestamp: u64) -> VestingInfo { + let vested = vested_amount(config, current_timestamp); + let claimable = vested.saturating_sub(config.claimed_amount).max(0); + VestingInfo { + total_allocation: config.total_allocation, + vested_amount: vested, + claimed_amount: config.claimed_amount, + claimable_amount: claimable, + cliff_timestamp: config.cliff_timestamp, + start_timestamp: config.start_timestamp, + duration_secs: config.duration_secs, + is_fully_vested: vested >= config.total_allocation, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_config() -> VestingConfig { + VestingConfig { + total_allocation: 10_000, + cliff_timestamp: 1_000, + start_timestamp: 0, + duration_secs: 10_000, + claimed_amount: 0, + } + } + + #[test] + fn test_vested_amount_returns_zero_before_cliff() { + let config = base_config(); + assert_eq!(vested_amount(&config, 0), 0); + assert_eq!(vested_amount(&config, 999), 0); + } + + #[test] + fn test_vested_amount_at_cliff_uses_linear_formula() { + let config = base_config(); + assert_eq!(vested_amount(&config, 1_000), 1_000); + } + + #[test] + fn test_vested_amount_mid_vesting_is_linear() { + let config = base_config(); + assert_eq!(vested_amount(&config, 5_000), 5_000); + assert_eq!(vested_amount(&config, 7_500), 7_500); + } + + #[test] + fn test_vested_amount_fully_vested_after_duration() { + let config = base_config(); + assert_eq!(vested_amount(&config, 10_000), 10_000); + assert_eq!(vested_amount(&config, 999_999), 10_000); + } + + #[test] + fn test_vested_amount_zero_duration_fully_vests_at_cliff() { + let config = VestingConfig { + duration_secs: 0, + ..base_config() + }; + assert_eq!(vested_amount(&config, 0), 0); + assert_eq!(vested_amount(&config, 1_000), 10_000); + } + + #[test] + fn test_vested_amount_zero_allocation_is_always_zero() { + let config = VestingConfig { + total_allocation: 0, + ..base_config() + }; + assert_eq!(vested_amount(&config, 10_000), 0); + } + + #[test] + fn test_claimable_amount_subtracts_already_claimed() { + let config = VestingConfig { + claimed_amount: 3_000, + ..base_config() + }; + assert_eq!(claimable_amount(&config, 5_000), 2_000); + } + + #[test] + fn test_claimable_amount_never_negative() { + let config = VestingConfig { + claimed_amount: 9_999, + ..base_config() + }; + assert_eq!(claimable_amount(&config, 0), 0); + } + + #[test] + fn test_get_vesting_info_pre_cliff() { + let config = base_config(); + let info = get_vesting_info(&config, 500); + assert_eq!(info.vested_amount, 0); + assert_eq!(info.claimable_amount, 0); + assert!(!info.is_fully_vested); + } + + #[test] + fn test_get_vesting_info_mid_vesting() { + let config = VestingConfig { + claimed_amount: 1_000, + ..base_config() + }; + let info = get_vesting_info(&config, 5_000); + assert_eq!(info.vested_amount, 5_000); + assert_eq!(info.claimable_amount, 4_000); + assert!(!info.is_fully_vested); + } + + #[test] + fn test_get_vesting_info_fully_vested() { + let config = VestingConfig { + claimed_amount: 10_000, + ..base_config() + }; + let info = get_vesting_info(&config, 20_000); + assert_eq!(info.claimable_amount, 0); + assert!(info.is_fully_vested); + } + + #[test] + fn test_vested_amount_no_rounding_errors_at_exact_duration_fraction() { + let config = VestingConfig { + total_allocation: 9_999, + cliff_timestamp: 0, + start_timestamp: 0, + duration_secs: 3, + claimed_amount: 0, + }; + assert_eq!(vested_amount(&config, 1), 3_333); + assert_eq!(vested_amount(&config, 2), 6_666); + assert_eq!(vested_amount(&config, 3), 9_999); + } +} diff --git a/creator-keys/tests/vesting_cliff_lifecycle.rs b/creator-keys/tests/vesting_cliff_lifecycle.rs new file mode 100644 index 00000000..a3d67555 --- /dev/null +++ b/creator-keys/tests/vesting_cliff_lifecycle.rs @@ -0,0 +1,97 @@ +mod contract_test_env; +use contract_test_env::{register_creator_keys, register_test_creator, test_env_with_auths}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::Address; + +#[test] +fn claim_before_cliff_reverts_with_no_claimable() { + let env = test_env_with_auths(); + let (client, _id) = register_creator_keys(&env); + let creator = register_test_creator(&env, &client, "vestcreator"); + let beneficiary = Address::generate(&env); + client.create_vesting_cliff( + &creator, + &creator, + &beneficiary, + &10_000i128, + &1_000u64, + &0u64, + &10_000u64, + ); + let result = client.try_claim_vested_cliff(&creator, &beneficiary); + assert!(result.is_err()); +} + +#[test] +fn claim_mid_vesting_returns_linear_amount() { + let env = test_env_with_auths(); + let (client, _id) = register_creator_keys(&env); + let creator = register_test_creator(&env, &client, "vestcreator2"); + let beneficiary = Address::generate(&env); + client.create_vesting_cliff( + &creator, + &creator, + &beneficiary, + &10_000i128, + &1_000u64, + &0u64, + &10_000u64, + ); + let mut l = env.ledger().get(); + l.timestamp = 5_000; + env.ledger().set(l); + let claimed = client.claim_vested_cliff(&creator, &beneficiary); + assert_eq!(claimed, 5_000); + let info = client.get_vesting_info(&creator, &beneficiary); + assert_eq!(info.claimed_amount, 5_000); + assert_eq!(info.claimable_amount, 0); +} + +#[test] +fn claim_after_full_duration_returns_full_allocation() { + let env = test_env_with_auths(); + let (client, _id) = register_creator_keys(&env); + let creator = register_test_creator(&env, &client, "vestcreator3"); + let beneficiary = Address::generate(&env); + client.create_vesting_cliff( + &creator, + &creator, + &beneficiary, + &10_000i128, + &1_000u64, + &0u64, + &10_000u64, + ); + let mut l = env.ledger().get(); + l.timestamp = 20_000; + env.ledger().set(l); + let claimed = client.claim_vested_cliff(&creator, &beneficiary); + assert_eq!(claimed, 10_000); +} + +#[test] +fn duplicate_schedule_is_rejected() { + let env = test_env_with_auths(); + let (client, _id) = register_creator_keys(&env); + let creator = register_test_creator(&env, &client, "vestcreator5"); + let beneficiary = Address::generate(&env); + client.create_vesting_cliff( + &creator, + &creator, + &beneficiary, + &10_000i128, + &1_000u64, + &0u64, + &10_000u64, + ); + let result = client.try_create_vesting_cliff( + &creator, + &creator, + &beneficiary, + &5_000i128, + &0u64, + &0u64, + &5_000u64, + ); + assert!(result.is_err()); +}