Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions creator-keys/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 109 additions & 1 deletion creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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<i128, ContractError> {
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<vesting::VestingInfo, ContractError> {
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,
Expand Down
204 changes: 204 additions & 0 deletions creator-keys/src/vesting.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading