From 2a7a5f6dad4c0593b5a7051b4e13090338195e65 Mon Sep 17 00:00:00 2001 From: ochedev6789 Date: Wed, 30 Sep 2026 05:17:43 +0000 Subject: [PATCH] feat: implement creator vesting contracts (#878) --- contracts/split/src/creator_vesting_ext.rs | 324 +++++++++++++++++++++ contracts/split/src/error.rs | 6 + contracts/split/src/events.rs | 34 +++ contracts/split/src/lib.rs | 5 + contracts/split/src/types.rs | 28 ++ docs/pr/ochedev6789-878.md | 67 +++++ 6 files changed, 464 insertions(+) create mode 100644 contracts/split/src/creator_vesting_ext.rs create mode 100644 docs/pr/ochedev6789-878.md diff --git a/contracts/split/src/creator_vesting_ext.rs b/contracts/split/src/creator_vesting_ext.rs new file mode 100644 index 0000000..bbbff7e --- /dev/null +++ b/contracts/split/src/creator_vesting_ext.rs @@ -0,0 +1,324 @@ +//! Issue #878: Creator vesting contracts. +//! +//! A creator can attach a linear vesting schedule to an invoice. Vesting is +//! time-based: the total amount linearly vests between `start_at` and `end_at` +//! with an optional cliff at `cliff_at`. No tokens are claimable before +//! `cliff_at`; after the cliff the full linearly-vested portion is claimable. +//! +//! The creator calls `claim_vested` to receive tokens that have vested since +//! the last claim. The contract holds no extra tokens — this tracks how much +//! of an already-released invoice amount the creator has claimed. +//! +//! Storage: own persistent key `VestingKey`. + +use crate::error::ContractError; +use crate::types::VestingSchedule; +use crate::{events, load_invoice, require_not_paused, SplitContract, SplitContractArgs, SplitContractClient}; +use soroban_sdk::{contractimpl, contracttype, panic_with_error, Address, Env, Symbol}; + +// --------------------------------------------------------------------------- +// Storage key +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VestingKey { + /// Per-invoice vesting schedule. + Schedule(u64), +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn load_schedule(env: &Env, invoice_id: u64) -> VestingSchedule { + env.storage() + .persistent() + .get(&VestingKey::Schedule(invoice_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::VestingNotFound)) +} + +/// Compute how much of `total_amount` has vested by `now`. +/// +/// Returns 0 before `start_at`, partial amount during vesting, and +/// `total_amount` after `end_at`. The cliff blocks any claims before +/// `cliff_at` even if vesting has technically started. +fn compute_vested(schedule: &VestingSchedule, now: u64) -> i128 { + if now < schedule.cliff_at { + return 0; + } + if now >= schedule.end_at { + return schedule.total_amount; + } + if now < schedule.start_at { + return 0; + } + let elapsed = (now - schedule.start_at) as i128; + let duration = (schedule.end_at - schedule.start_at) as i128; + if duration == 0 { + return schedule.total_amount; + } + schedule.total_amount * elapsed / duration +} + +// --------------------------------------------------------------------------- +// Contract entry points +// --------------------------------------------------------------------------- + +#[contractimpl] +impl SplitContract { + /// Attach a vesting schedule to `invoice_id` (creator only). + /// + /// - `total_amount`: the total token amount subject to vesting. + /// - `start_at`: Unix timestamp when linear vesting begins. + /// - `cliff_at`: Unix timestamp before which nothing is claimable + /// (must be ≥ `start_at`). + /// - `end_at`: Unix timestamp when the full amount is vested (must be + /// > `start_at`). + /// + /// The invoice must exist. Overwrites any existing schedule. + pub fn create_vesting_schedule( + env: Env, + creator: Address, + invoice_id: u64, + total_amount: i128, + start_at: u64, + cliff_at: u64, + end_at: u64, + ) { + require_not_paused(&env); + creator.require_auth(); + let invoice = load_invoice(&env, invoice_id); + if invoice.creator != creator { + panic_with_error!(&env, ContractError::NotAuthorized); + } + if total_amount <= 0 { + panic_with_error!(&env, ContractError::InvalidAmount); + } + if cliff_at < start_at || end_at <= start_at { + panic_with_error!(&env, ContractError::InvalidAmount); + } + let schedule = VestingSchedule { + invoice_id, + creator: creator.clone(), + total_amount, + released_amount: 0, + start_at, + cliff_at, + end_at, + }; + env.storage() + .persistent() + .set(&VestingKey::Schedule(invoice_id), &schedule); + events::vesting_schedule_created(&env, invoice_id, &creator, total_amount, start_at, cliff_at, end_at); + } + + /// Claim the currently vested but unclaimed tokens (creator only). + /// + /// Panics with `VestingNotFound` if no schedule exists, and + /// `VestingCliffNotReached` if called before the cliff, and + /// `VestingAlreadyComplete` if everything has already been claimed. + pub fn claim_vested(env: Env, creator: Address, invoice_id: u64) -> i128 { + require_not_paused(&env); + creator.require_auth(); + let mut schedule = load_schedule(&env, invoice_id); + if schedule.creator != creator { + panic_with_error!(&env, ContractError::NotAuthorized); + } + let now = env.ledger().timestamp(); + if now < schedule.cliff_at { + panic_with_error!(&env, ContractError::VestingCliffNotReached); + } + if schedule.released_amount >= schedule.total_amount { + panic_with_error!(&env, ContractError::VestingAlreadyComplete); + } + let vested = compute_vested(&schedule, now); + let claimable = vested - schedule.released_amount; + if claimable <= 0 { + panic_with_error!(&env, ContractError::VestingCliffNotReached); + } + schedule.released_amount += claimable; + env.storage() + .persistent() + .set(&VestingKey::Schedule(invoice_id), &schedule); + events::vesting_claimed(&env, invoice_id, &creator, claimable, schedule.released_amount); + claimable + } + + /// Return the vesting schedule for `invoice_id`, or `None` if none exists. + pub fn get_vesting_schedule(env: Env, invoice_id: u64) -> Option { + env.storage() + .persistent() + .get(&VestingKey::Schedule(invoice_id)) + } + + /// Return how much of the vesting schedule has vested by the current + /// ledger timestamp (regardless of what has already been claimed). + pub fn get_vested_amount(env: Env, invoice_id: u64) -> i128 { + let schedule: VestingSchedule = env + .storage() + .persistent() + .get(&VestingKey::Schedule(invoice_id)) + .unwrap_or_else(|| panic_with_error!(&env, ContractError::VestingNotFound)); + compute_vested(&schedule, env.ledger().timestamp()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; + use soroban_sdk::{vec, Env}; + + fn setup() -> (Env, SplitContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, SplitContract); + let client = SplitContractClient::new(&env, &contract_id); + client.initialize( + &Address::generate(&env), + &0_i128, + &0_u32, + &Address::generate(&env), + ); + (env, client) + } + + #[test] + fn test_create_and_get_vesting_schedule() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient.clone()], + &vec![&env, 1000_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + client.create_vesting_schedule( + &creator, + &invoice_id, + &1000_i128, + &100_u64, + &200_u64, + &1100_u64, + ); + let schedule = client.get_vesting_schedule(&invoice_id).unwrap(); + assert_eq!(schedule.total_amount, 1000_i128); + assert_eq!(schedule.released_amount, 0_i128); + assert_eq!(schedule.cliff_at, 200_u64); + } + + #[test] + fn test_get_vesting_schedule_none() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient], + &vec![&env, 500_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + assert!(client.get_vesting_schedule(&invoice_id).is_none()); + } + + #[test] + #[should_panic] + fn test_only_creator_can_create_schedule() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let other = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient], + &vec![&env, 500_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + client.create_vesting_schedule(&other, &invoice_id, &500_i128, &100_u64, &100_u64, &1100_u64); + } + + #[test] + #[should_panic] + fn test_claim_before_cliff_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + // Set ledger time well before cliff + env.ledger().set(LedgerInfo { + timestamp: 50, + ..env.ledger().get() + }); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient], + &vec![&env, 1000_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + client.create_vesting_schedule( + &creator, + &invoice_id, + &1000_i128, + &100_u64, + &500_u64, + &2000_u64, + ); + // Still before cliff + client.claim_vested(&creator, &invoice_id); + } + + #[test] + #[should_panic] + fn test_get_vested_amount_no_schedule_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient], + &vec![&env, 500_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + client.get_vested_amount(&invoice_id); + } + + #[test] + #[should_panic] + fn test_invalid_cliff_before_start_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + let invoice_id = client.create_invoice( + &creator, + &vec![&env, recipient], + &vec![&env, 500_i128], + &token, + &(env.ledger().timestamp() + 10_000), + ); + // cliff_at < start_at — invalid + client.create_vesting_schedule( + &creator, + &invoice_id, + &500_i128, + &500_u64, + &100_u64, // cliff before start + &2000_u64, + ); + } +} diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index b0c9513..c91f0bf 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -181,4 +181,10 @@ pub enum ContractError { TokenExpired = 82, /// Issue #869: Caller is not the current token holder. NotTokenHolder = 83, + /// Issue #878: No vesting schedule exists for this invoice. + VestingNotFound = 84, + /// Issue #878: The cliff timestamp has not yet been reached; nothing is claimable. + VestingCliffNotReached = 85, + /// Issue #878: The vesting schedule is complete; all tokens have been claimed. + VestingAlreadyComplete = 86, } diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 3ed248d..179e8e4 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -2738,3 +2738,37 @@ pub fn covenant_violated(env: &Env, invoice_id: u64, creator: &Address, penalty_ (creator.clone(), penalty_amount), ); } + +// --------------------------------------------------------------------------- +// #878 – Creator vesting events +// --------------------------------------------------------------------------- + +/// Emitted when a creator attaches a vesting schedule to an invoice. +pub fn vesting_schedule_created( + env: &Env, + invoice_id: u64, + creator: &Address, + total_amount: i128, + start_at: u64, + cliff_at: u64, + end_at: u64, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("vest_crt"), invoice_id), + (creator.clone(), total_amount, start_at, cliff_at, end_at), + ); +} + +/// Emitted when a creator claims a portion of their vested tokens. +pub fn vesting_claimed( + env: &Env, + invoice_id: u64, + creator: &Address, + claimed_amount: i128, + total_released: i128, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("vest_clm"), invoice_id), + (creator.clone(), claimed_amount, total_released), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 81e915f..0069f23 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -112,6 +112,9 @@ mod earnings_insurance; mod invoice_links; mod liquidity_pool; +// Issue #878: creator vesting contracts. +mod creator_vesting_ext; + use error::ContractError; use validation::assert_valid_bps; use calc::{calc_platform_fee, funding_bps}; @@ -158,6 +161,8 @@ use types::{ InvoiceTimeLock, // Issue #869 RedemptionToken, + // Issue #878 + VestingSchedule, }; // --------------------------------------------------------------------------- diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index e11d439..768088f 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -2337,3 +2337,31 @@ pub struct CreatorCovenant { /// Whether the covenant was fulfilled (invoice released on time). pub fulfilled: bool, } + +// --------------------------------------------------------------------------- +// #878 – Creator vesting contracts +// --------------------------------------------------------------------------- + +/// A linear vesting schedule attached to an invoice by its creator. +/// +/// Tokens vest linearly between `start_at` and `end_at`. Nothing is claimable +/// before `cliff_at` even if vesting has started. The creator calls +/// `claim_vested` to withdraw the accrued portion. +#[contracttype] +#[derive(Clone, Debug)] +pub struct VestingSchedule { + /// Invoice this schedule is attached to. + pub invoice_id: u64, + /// Creator who owns this vesting schedule. + pub creator: Address, + /// Total amount subject to vesting. + pub total_amount: i128, + /// Amount already claimed by the creator. + pub released_amount: i128, + /// Unix timestamp when linear vesting begins. + pub start_at: u64, + /// Unix timestamp before which nothing is claimable. + pub cliff_at: u64, + /// Unix timestamp at which the full amount is vested. + pub end_at: u64, +} diff --git a/docs/pr/ochedev6789-878.md b/docs/pr/ochedev6789-878.md new file mode 100644 index 0000000..2b9c57d --- /dev/null +++ b/docs/pr/ochedev6789-878.md @@ -0,0 +1,67 @@ +# Issue #878 — Implement creator vesting contracts + +Implements a linear vesting schedule system that allows invoice creators to +attach time-based vesting schedules to their invoices. + +--- + +## #878 — Creator vesting contracts + +**New file:** `contracts/split/src/creator_vesting_ext.rs` + +**Core functionality** + +A creator can attach a linear vesting schedule to any invoice they own. The +schedule defines a `total_amount`, a `start_at` timestamp when linear vesting +begins, a `cliff_at` timestamp before which nothing is claimable, and an +`end_at` timestamp when the full amount is vested. The creator calls +`claim_vested` to receive the currently vested but unclaimed portion. + +Vesting formula (after cliff): +``` +vested = total_amount * (now - start_at) / (end_at - start_at) +claimable = vested - released_amount +``` + +**Entry points** +- `create_vesting_schedule(creator, invoice_id, total_amount, start_at, cliff_at, end_at)` — creator only; must own the invoice; `cliff_at ≥ start_at` and `end_at > start_at`. +- `claim_vested(creator, invoice_id) -> i128` — creator only; panics with `VestingCliffNotReached` if before cliff, `VestingAlreadyComplete` if fully claimed. +- `get_vesting_schedule(invoice_id) -> Option` — returns the schedule or `None`. +- `get_vested_amount(invoice_id) -> i128` — current vested amount (total, not net of claims). + +**Events** +- `(split, vest_crt, invoice_id)` — schedule created +- `(split, vest_clm, invoice_id)` — tokens claimed + +**New storage key** +- `VestingKey::Schedule(invoice_id)` — private enum in `creator_vesting_ext.rs` (no `InvoiceKey` variant needed) + +**New type:** `VestingSchedule` in `types.rs` + +**New errors:** +- `VestingNotFound = 84` — no schedule exists for this invoice +- `VestingCliffNotReached = 85` — nothing claimable yet (before cliff) +- `VestingAlreadyComplete = 86` — all tokens already claimed + +**Tests:** 6 unit tests covering: create and get schedule, get none when absent, only creator can create, claim before cliff panics, get vested amount without schedule panics, invalid cliff before start panics. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `contracts/split/src/creator_vesting_ext.rs` | **New** — issue #878 | +| `contracts/split/src/lib.rs` | Added `mod creator_vesting_ext;` and `VestingSchedule` type import | +| `contracts/split/src/types.rs` | Added `VestingSchedule` struct | +| `contracts/split/src/error.rs` | Added `VestingNotFound = 84`, `VestingCliffNotReached = 85`, `VestingAlreadyComplete = 86` | +| `contracts/split/src/events.rs` | Added `vesting_schedule_created` and `vesting_claimed` event functions | + +## Verification + +- New module follows the same `#[contractimpl] impl SplitContract` pattern as `dynamic_fee.rs`, `earnings_insurance.rs`, and other established extension modules. +- Error codes 84–86 are sequential continuations from the last assigned code (83). +- No existing code paths modified — purely additive change. +- No storage key collisions — `VestingKey` is a private enum in the module. + +Closes #878