diff --git a/contracts/vesting_curves/Cargo.toml b/contracts/vesting_curves/Cargo.toml new file mode 100644 index 0000000..c11052a --- /dev/null +++ b/contracts/vesting_curves/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +resolver = "2" +members = ["contracts/vesting-vault"] + +[workspace.dependencies] +soroban-sdk = "22.0.0" + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/contracts/vesting_curves/README.md b/contracts/vesting_curves/README.md new file mode 100644 index 0000000..5fa6bae --- /dev/null +++ b/contracts/vesting_curves/README.md @@ -0,0 +1,36 @@ +--- + +### Summary +Introduces the `VestingCurve` enum to support both **Linear** and **Exponential** vesting schedules in the Vesting Vault contract. This enhancement allows flexible release patterns, enabling either steady vesting or a slow-start/accelerated curve, while ensuring correctness through unit and integration tests. + +### Key Features +* **VestingCurve Enum**: + - `Linear`: vested = total × elapsed ÷ duration + - `Exponential`: vested = total × elapsed² ÷ duration² +* **Function Dispatch**: `vested_amount`, `claim`, and `status` now branch on curve type. +* **Mathematical Behavior**: + - Linear: proportional vesting (50% time → 50% tokens). + - Exponential: slower start, faster finish (50% time → 25% tokens). +* **Immutable Curve**: Curve set at `initialize()` and cannot be changed mid-schedule. +* **Incremental Claim Guard**: Ensures multiple claims sum correctly regardless of curve. +* **Testing**: 11 unit + integration tests validating math, claims, and curve behavior. + +### How to Test +1. Run `cargo test` in the `vesting-vault` workspace. + - ✅ All 11 tests should pass, covering both Linear and Exponential curves. +2. Build the WASM binary: `stellar contract build`. +3. Deploy to Stellar Testnet with `stellar contract deploy`. +4. Initialize vaults with `--curve '{"Linear": {}}'` and `--curve '{"Exponential": {}}'`. +5. Invoke `get_curve` to confirm correct variant. +6. Check `vested_amount` at 50% elapsed: Linear → 50%, Exponential → 25%. +7. Use `claim` to verify incremental transfers. + +### Checklist +- [x] Add `VestingCurve` enum with Linear & Exponential variants +- [x] Update `vested_amount`, `claim`, and `status` to dispatch on curve +- [x] Ensure integer-only math with `u128` intermediates +- [x] Enforce immutable curve at initialization +- [x] Implement incremental claim logic +- [x] Write 11 unit/integration tests for both curves +- [x] Build & deploy WASM contract to Stellar Testnet + diff --git a/contracts/vesting_curves/src/lib.rs b/contracts/vesting_curves/src/lib.rs new file mode 100644 index 0000000..6d6ae65 --- /dev/null +++ b/contracts/vesting_curves/src/lib.rs @@ -0,0 +1,184 @@ + +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol, +}; + +// --------------------------------------------------------------------------- +// Storage key symbols +// --------------------------------------------------------------------------- +const ADMIN: Symbol = symbol_short!("ADMIN"); +const BENEFICIARY: Symbol = symbol_short!("BENE"); +const TOKEN: Symbol = symbol_short!("TOKEN"); +const TOTAL: Symbol = symbol_short!("TOTAL"); +const CLAIMED: Symbol = symbol_short!("CLAIMED"); +const START: Symbol = symbol_short!("START"); +const DURATION: Symbol = symbol_short!("DURATION"); +const CURVE: Symbol = symbol_short!("CURVE"); + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum VestingCurve { + Linear, + Exponential, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct VestingVault; + +#[contractimpl] +impl VestingVault { + // ----------------------------------------------------------------------- + // Initialisation + // ----------------------------------------------------------------------- + + pub fn initialize( + env: Env, + admin: Address, + beneficiary: Address, + token: Address, + total_amount: i128, + start: u64, + duration: u64, + curve: VestingCurve, + ) { + // Prevent re-initialisation + if env.storage().instance().has(&ADMIN) { + panic!("already initialized"); + } + + assert!(total_amount > 0, "total_amount must be positive"); + assert!(duration > 0, "duration must be positive"); + + admin.require_auth(); + + env.storage().instance().set(&ADMIN, &admin); + env.storage().instance().set(&BENEFICIARY, &beneficiary); + env.storage().instance().set(&TOKEN, &token); + env.storage().instance().set(&TOTAL, &total_amount); + env.storage().instance().set(&CLAIMED, &0_i128); + env.storage().instance().set(&START, &start); + env.storage().instance().set(&DURATION, &duration); + env.storage().instance().set(&CURVE, &curve); + } + + // ----------------------------------------------------------------------- + // Core maths (Issue #6 acceptance criterion 2) + // ----------------------------------------------------------------------- + + pub fn vested_amount(env: Env, now: u64) -> i128 { + let total: i128 = env.storage().instance().get(&TOTAL).unwrap(); + let start: u64 = env.storage().instance().get(&START).unwrap(); + let duration: u64 = env.storage().instance().get(&DURATION).unwrap(); + let curve: VestingCurve = env.storage().instance().get(&CURVE).unwrap(); + + Self::compute_vested(total, start, duration, now, &curve) + } + + fn compute_vested( + total: i128, + start: u64, + duration: u64, + now: u64, + curve: &VestingCurve, + ) -> i128 { + if now <= start { + return 0; + } + + let elapsed = now - start; + + if elapsed >= duration { + return total; // fully vested + } + + match curve { + + VestingCurve::Linear => { + + (total * elapsed as i128) / duration as i128 + } + + VestingCurve::Exponential => { + let elapsed_u128 = elapsed as u128; + let duration_u128 = duration as u128; + let total_u128 = total as u128; + + let numerator = total_u128 * elapsed_u128 * elapsed_u128; + let denominator = duration_u128 * duration_u128; + + (numerator / denominator) as i128 + } + } + } + + // ----------------------------------------------------------------------- + // Claim + // ----------------------------------------------------------------------- + + pub fn claim(env: Env) -> i128 { + let beneficiary: Address = env.storage().instance().get(&BENEFICIARY).unwrap(); + beneficiary.require_auth(); + + let now = env.ledger().timestamp(); + let vested = Self::compute_vested( + env.storage().instance().get(&TOTAL).unwrap(), + env.storage().instance().get(&START).unwrap(), + env.storage().instance().get(&DURATION).unwrap(), + now, + &env.storage().instance().get::(&CURVE).unwrap(), + ); + + let claimed: i128 = env.storage().instance().get(&CLAIMED).unwrap(); + let claimable = vested - claimed; + + assert!(claimable > 0, "nothing to claim"); + + // Transfer tokens from vault to beneficiary + let token: Address = env.storage().instance().get(&TOKEN).unwrap(); + let token_client = soroban_sdk::token::Client::new(&env, &token); + token_client.transfer( + &env.current_contract_address(), + &beneficiary, + &claimable, + ); + + // Record the new claimed total + env.storage().instance().set(&CLAIMED, &vested); + + claimable + } + + // ----------------------------------------------------------------------- + // View helpers + // ----------------------------------------------------------------------- + + pub fn get_curve(env: Env) -> VestingCurve { + env.storage().instance().get(&CURVE).unwrap() + } + + pub fn status(env: Env) -> (i128, i128, i128, i128) { + let total: i128 = env.storage().instance().get(&TOTAL).unwrap(); + let claimed: i128 = env.storage().instance().get(&CLAIMED).unwrap(); + let vested = Self::compute_vested( + total, + env.storage().instance().get(&START).unwrap(), + env.storage().instance().get(&DURATION).unwrap(), + env.ledger().timestamp(), + &env.storage().instance().get::(&CURVE).unwrap(), + ); + (total, claimed, vested, vested - claimed) + } +} + +// --------------------------------------------------------------------------- +// Unit Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test; \ No newline at end of file diff --git a/contracts/vesting_curves/src/test.rs b/contracts/vesting_curves/src/test.rs new file mode 100644 index 0000000..6c018bc --- /dev/null +++ b/contracts/vesting_curves/src/test.rs @@ -0,0 +1,258 @@ + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +use crate::{VestingCurve, VestingVaultClient}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TOTAL: i128 = 1_000_000_000_i128; +const START: u64 = 1_000_000_u64; +const DURATION: u64 = 1_000_u64; + +struct Setup { + env: Env, + vault: VestingVaultClient<'static>, + token: Address, + admin: Address, + beneficiary: Address, +} + +fn create_setup(curve: VestingCurve) -> Setup { + let env = Env::default(); + env.mock_all_auths(); + + // Create a native/SAC token + let admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token_admin = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token = token_id.address(); + + StellarAssetClient::new(&env, &token).mint(&admin, &TOTAL); + + // Register the vault contract + let vault_id = env.register(crate::VestingVault, ()); + let vault = VestingVaultClient::new(&env, &vault_id); + + // Transfer tokens from admin to the vault contract + TokenClient::new(&env, &token).transfer(&admin, &vault_id, &TOTAL); + + // Set ledger time to START so initialization is clean + env.ledger().with_mut(|l| l.timestamp = START); + + vault.initialize( + &admin, + &beneficiary, + &token, + &TOTAL, + &START, + &DURATION, + &curve, + ); + + Setup { env, vault, token, admin, beneficiary } +} + +// --------------------------------------------------------------------------- +// Pure maths tests (no Env storage needed – test compute_vested directly) +// --------------------------------------------------------------------------- + +fn vested_at(env: &Env, vault: &VestingVaultClient, ts: u64) -> i128 { + env.ledger().with_mut(|l| l.timestamp = ts); + vault.vested_amount(&ts) +} + +// ── Linear ────────────────────────────────────────────────────────────────── + +#[test] +fn l1_linear_at_start_is_zero() { + let s = create_setup(VestingCurve::Linear); + assert_eq!(vested_at(&s.env, &s.vault, START), 0); +} + +#[test] +fn l2_linear_at_half_is_fifty_percent() { + let s = create_setup(VestingCurve::Linear); + let expected = TOTAL / 2; + let actual = vested_at(&s.env, &s.vault, START + DURATION / 2); + assert_eq!(actual, expected, "linear 50% failed: got {actual}"); +} + +#[test] +fn l3_linear_at_end_is_full() { + let s = create_setup(VestingCurve::Linear); + assert_eq!(vested_at(&s.env, &s.vault, START + DURATION), TOTAL); +} + +#[test] +fn l4_linear_after_end_capped_at_full() { + let s = create_setup(VestingCurve::Linear); + assert_eq!(vested_at(&s.env, &s.vault, START + DURATION + 9999), TOTAL); +} + +// ── Exponential ───────────────────────────────────────────────────────────── + +#[test] +fn e1_expo_at_start_is_zero() { + let s = create_setup(VestingCurve::Exponential); + assert_eq!(vested_at(&s.env, &s.vault, START), 0); +} + +#[test] +fn e2_expo_at_quarter_is_6_25_percent() { + let s = create_setup(VestingCurve::Exponential); + // elapsed=250, duration=1000 → (250/1000)^2 = 0.0625 → 62_500_000 + let elapsed = DURATION / 4; + let expected = TOTAL * (elapsed as i128 * elapsed as i128) + / (DURATION as i128 * DURATION as i128); + let actual = vested_at(&s.env, &s.vault, START + elapsed); + assert_eq!(actual, expected, "expo 25% elapsed failed: got {actual}"); +} + +#[test] +fn e3_expo_at_half_is_twenty_five_percent() { + let s = create_setup(VestingCurve::Exponential); + let expected = TOTAL / 4; // 0.5^2 = 0.25 + let actual = vested_at(&s.env, &s.vault, START + DURATION / 2); + assert_eq!(actual, expected, "expo 50% elapsed failed: got {actual}"); +} + +#[test] +fn e4_expo_at_three_quarters_is_56_25_percent() { + let s = create_setup(VestingCurve::Exponential); + let elapsed = (DURATION * 3) / 4; + let expected = TOTAL * (elapsed as i128 * elapsed as i128) + / (DURATION as i128 * DURATION as i128); + let actual = vested_at(&s.env, &s.vault, START + elapsed); + assert_eq!(actual, expected, "expo 75% elapsed failed: got {actual}"); +} + +#[test] +fn e5_expo_at_end_is_full() { + let s = create_setup(VestingCurve::Exponential); + assert_eq!(vested_at(&s.env, &s.vault, START + DURATION), TOTAL); +} + +#[test] +fn e6_expo_after_end_capped_at_full() { + let s = create_setup(VestingCurve::Exponential); + assert_eq!(vested_at(&s.env, &s.vault, START + DURATION + 5000), TOTAL); +} + +// ── Comparison ────────────────────────────────────────────────────────────── + +#[test] +fn c1_at_midpoint_exponential_less_than_linear() { + let sl = create_setup(VestingCurve::Linear); + let se = create_setup(VestingCurve::Exponential); + let mid = START + DURATION / 2; + + let linear_mid = vested_at(&sl.env, &sl.vault, mid); + let expo_mid = vested_at(&se.env, &se.vault, mid); + + assert!( + expo_mid < linear_mid, + "Expected expo ({expo_mid}) < linear ({linear_mid}) at midpoint" + ); +} + +// ── Integration tests ──────────────────────────────────────────────────────── + +#[test] +fn i1_linear_claim_at_halfway() { + let s = create_setup(VestingCurve::Linear); + + // Advance ledger to 50 % of vesting period + s.env.ledger().with_mut(|l| l.timestamp = START + DURATION / 2); + + let claimed = s.vault.claim(); + assert_eq!(claimed, TOTAL / 2, "linear claim at 50%: got {claimed}"); + + // Beneficiary balance should match + let bal = TokenClient::new(&s.env, &s.token).balance(&s.beneficiary); + assert_eq!(bal, TOTAL / 2); +} + +#[test] +fn i2_exponential_claim_at_three_quarters() { + let s = create_setup(VestingCurve::Exponential); + + let elapsed = (DURATION * 3) / 4; + let expected = TOTAL * (elapsed as i128 * elapsed as i128) + / (DURATION as i128 * DURATION as i128); + + s.env.ledger().with_mut(|l| l.timestamp = START + elapsed); + + let claimed = s.vault.claim(); + assert_eq!(claimed, expected, "expo claim at 75%: got {claimed}"); + + let bal = TokenClient::new(&s.env, &s.token).balance(&s.beneficiary); + assert_eq!(bal, expected); +} + +#[test] +fn i3_get_curve_returns_correct_variant() { + let sl = create_setup(VestingCurve::Linear); + let se = create_setup(VestingCurve::Exponential); + + assert_eq!(sl.vault.get_curve(), VestingCurve::Linear); + assert_eq!(se.vault.get_curve(), VestingCurve::Exponential); +} + +#[test] +#[should_panic(expected = "nothing to claim")] +fn i4_claim_before_any_vesting_panics() { + let s = create_setup(VestingCurve::Linear); + // Ledger is at START – nothing vested yet + s.vault.claim(); +} + +#[test] +fn i5_status_helper_is_consistent() { + let s = create_setup(VestingCurve::Linear); + + s.env.ledger().with_mut(|l| l.timestamp = START + DURATION / 4); + let (total, claimed, vested, claimable) = s.vault.status(); + + assert_eq!(total, TOTAL); + assert_eq!(claimed, 0); + assert_eq!(vested, TOTAL / 4); + assert_eq!(claimable, TOTAL / 4); + + // Now claim and re-check + s.vault.claim(); + let (_, claimed2, vested2, claimable2) = s.vault.status(); + assert_eq!(claimed2, TOTAL / 4); + assert_eq!(vested2, TOTAL / 4); + assert_eq!(claimable2, 0); +} + +#[test] +fn i6_double_claim_only_yields_incremental_amount() { + let s = create_setup(VestingCurve::Exponential); + + // First claim at 50 % + s.env.ledger().with_mut(|l| l.timestamp = START + DURATION / 2); + let first_claim = s.vault.claim(); + assert_eq!(first_claim, TOTAL / 4); // 0.5^2 * TOTAL + + // Advance to 100 % + s.env.ledger().with_mut(|l| l.timestamp = START + DURATION); + let second_claim = s.vault.claim(); + assert_eq!(second_claim, TOTAL - TOTAL / 4); // remaining 75 % + + // Total received = TOTAL + let bal = TokenClient::new(&s.env, &s.token).balance(&s.beneficiary); + assert_eq!(bal, TOTAL); +} \ No newline at end of file diff --git a/target/.rustc_info.json b/target/.rustc_info.json index 341b919..184f963 100644 --- a/target/.rustc_info.json +++ b/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":15017765183784914989,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\USER\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: x86_64-pc-windows-msvc\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":10707413240080031435,"outputs":{"12004014463585500860":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Dell\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.90.0 (1159e78c4 2025-09-14)\nbinary: rustc\ncommit-hash: 1159e78c4747b02ef996e55082b704c09b970588\ncommit-date: 2025-09-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.90.0\nLLVM version: 20.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Dell\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}} \ No newline at end of file