diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64c44ef..22e6d56 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,9 +25,26 @@ jobs: - name: Install Stellar CLI run: | - wget https://github.com/stellar/stellar-cli/releases/download/v25.1.0/stellar-cli-25.1.0-x86_64-unknown-linux-gnu.tar.gz - tar -xzf stellar-cli-25.1.0-x86_64-unknown-linux-gnu.tar.gz - sudo mv stellar-cli-25.1.0-x86_64-unknown-linux-gnu/stellar /usr/local/bin/ + set -euo pipefail + + VERSION="25.1.0" + ARCHIVE="stellar-cli-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" + URL="https://github.com/stellar/stellar-cli/releases/download/v${VERSION}/${ARCHIVE}" + + wget "${URL}" -O "${ARCHIVE}" + + # Package layout has changed across versions; locate the binary from the archive contents. + BIN_PATH="$(tar -tzf "${ARCHIVE}" | grep -E '(^|/)stellar(-cli)?$' | head -n 1 || true)" + if [ -z "${BIN_PATH}" ]; then + echo "Could not locate stellar CLI binary in ${ARCHIVE}" + tar -tzf "${ARCHIVE}" + exit 1 + fi + + tar -xzf "${ARCHIVE}" + + sudo install -m 0755 "${BIN_PATH}" /usr/local/bin/stellar + stellar --version - name: Build Contract run: cargo build --target wasm32-unknown-unknown --release diff --git a/contracts/vesting_contracts/src/factory.rs b/contracts/vesting_contracts/src/factory.rs index 93c1cd7..5710062 100644 --- a/contracts/vesting_contracts/src/factory.rs +++ b/contracts/vesting_contracts/src/factory.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contract, contractimpl, contractmeta, contracttype, Map, Address, BytesN, Env, Vec}; +use soroban_sdk::{ + contract, contractimpl, contractmeta, contracttype, Address, BytesN, Env, Map, Vec, +}; // Contract metadata for the factory contractmeta!( @@ -18,7 +20,7 @@ enum DataKey { #[contractimpl] impl VestingFactory { /// Initialize the factory with the WASM hash of the vesting contract - pub fn initialize(env: Env, wasm_hash: BytesN<32>) { + pub fn initialize_factory(env: Env, wasm_hash: BytesN<32>) { // Store the WASM hash for future deployments env.storage().instance().set(&DataKey::WasmHash, &wasm_hash); @@ -31,7 +33,12 @@ impl VestingFactory { /// Deploy a new vesting contract for an organization /// Only allows deployment if token is whitelisted - pub fn deploy_new_vault_contract(env: Env, admin: Address, initial_supply: i128, token: Address) -> Address { + pub fn deploy_new_vault_contract( + env: Env, + admin: Address, + initial_supply: i128, + token: Address, + ) -> Address { let _wasm_hash: BytesN<32> = env .storage() .instance() @@ -39,7 +46,11 @@ impl VestingFactory { .unwrap_or_else(|| panic!("Factory not initialized - WASM hash not set")); // Check token whitelist - let whitelist: Map = env.storage().instance().get(&crate::WhitelistDataKey::WhitelistedTokens).unwrap_or(Map::new(&env)); + let whitelist: Map = env + .storage() + .instance() + .get(&crate::WhitelistDataKey::WhitelistedTokens) + .unwrap_or(Map::new(&env)); if !whitelist.get(token.clone()).unwrap_or(false) { panic!("Token not whitelisted"); } diff --git a/contracts/vesting_contracts/src/lib.rs b/contracts/vesting_contracts/src/lib.rs index 726dad6..dfb2438 100644 --- a/contracts/vesting_contracts/src/lib.rs +++ b/contracts/vesting_contracts/src/lib.rs @@ -1,11 +1,9 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, vec, Address, Env, IntoVal, Map, Symbol, Vec, String}; + use soroban_sdk::{ - contract, contractimpl, contracttype, token, vec, Address, Env, IntoVal, Map, Symbol, Vec, - String, + contract, contractimpl, contracttype, token, vec, Address, Env, IntoVal, Map, String, Symbol, + Vec, }; - #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, vec, Address, Env, IntoVal, Map, Symbol, Vec}; // DataKey for whitelisted tokens #[contracttype] @@ -27,40 +25,17 @@ pub enum DataKey { IsPaused, IsDeprecated, MigrationTarget, -} - -mod factory; -pub use factory::{VestingFactory, VestingFactoryClient}; - -#[contract] -pub struct VestingContract; - -// Vault structure with lazy initialization -#[contracttype] -pub enum DataKey { - AdminAddress, - AdminBalance, - InitialSupply, - ProposedAdmin, - VaultCount, - VaultData(u64), - VaultMilestones(u64), - UserVaults(Address), - IsPaused, - KeeperFees, - Token, // yield-bearing token - TotalShares, // remaining initial_deposit_shares + Token, // yield-bearing token + TotalShares, // remaining initial_deposit_shares TotalStaked, } #[contracttype] #[derive(Clone)] -// NOTE: `#[contracttype]` structs serialize by field order (tuple-style). -// Reordering fields is a storage schema change; only do this pre-deploy or with migration. pub struct Vault { pub total_amount: i128, // = initial_deposit_shares pub released_amount: i128, - pub keeper_fee: i128, // Fee paid to anyone who triggers auto_claim + pub keeper_fee: i128, // Fee paid to anyone who triggers auto_claim pub staked_amount: i128, // Amount currently staked in external contract pub owner: Address, @@ -72,19 +47,10 @@ pub struct Vault { pub creation_time: u64, // Timestamp of creation for clawback grace period pub step_duration: u64, // Duration of each vesting step in seconds (0 = linear) - pub is_initialized: bool, // Lazy initialization flag - pub is_irrevocable: bool, // Security flag to prevent admin withdrawal + pub is_initialized: bool, // Lazy initialization flag + pub is_irrevocable: bool, // Security flag to prevent admin withdrawal pub is_transferable: bool, // Can the beneficiary transfer this vault? - pub step_duration: u64, // Duration of each vesting step in seconds (0 = linear) - pub staked_amount: i128, // Amount currently staked in external contract - pub is_frozen: bool, // Individual vault freeze flag for security investigations - pub keeper_fee: i128, - pub is_initialized: bool, - pub is_irrevocable: bool, - pub creation_time: u64, - pub is_transferable: bool, - pub step_duration: u64, - pub staked_amount: i128, + pub is_frozen: bool, // Individual vault freeze flag for security investigations } #[contracttype] @@ -181,34 +147,29 @@ impl VestingContract { pub fn initialize(env: Env, admin: Address, initial_supply: i128) { Self::require_not_deprecated(&env); + env.storage().instance().set(&DataKey::AdminAddress, &admin); env.storage() .instance() .set(&DataKey::InitialSupply, &initial_supply); - env.storage() .instance() .set(&DataKey::AdminBalance, &initial_supply); - - pub fn initialize(env: Env, admin: Address, initial_supply: i128) { - 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().set(&DataKey::VaultCount, &0u64); // Initialize pause state to false (unpaused) env.storage().instance().set(&DataKey::IsPaused, &false); // Initialize deprecated state to false (active) - env.storage() - .instance() - .set(&DataKey::IsDeprecated, &false); + env.storage().instance().set(&DataKey::IsDeprecated, &false); // Clear migration target on init env.storage().instance().remove(&DataKey::MigrationTarget); // Initialize whitelisted tokens map let whitelist: Map = Map::new(&env); - env.storage().instance().set(&WhitelistDataKey::WhitelistedTokens, &whitelist); + env.storage() + .instance() + .set(&WhitelistDataKey::WhitelistedTokens, &whitelist); env.storage().instance().set(&DataKey::TotalShares, &0i128); env.storage().instance().set(&DataKey::TotalStaked, &0i128); @@ -260,7 +221,11 @@ impl VestingContract { pct = pct.saturating_add(m.percentage); } } - if pct > 100 { 100 } else { pct } + if pct > 100 { + 100 + } else { + pct + } } fn unlocked_amount(total_amount: i128, unlocked_percentage: u32) -> i128 { @@ -269,7 +234,9 @@ impl VestingContract { pub fn propose_new_admin(env: Env, new_admin: Address) { Self::require_admin(&env); - env.storage().instance().set(&DataKey::ProposedAdmin, &new_admin); + env.storage() + .instance() + .set(&DataKey::ProposedAdmin, &new_admin); } pub fn accept_ownership(env: Env) { @@ -280,7 +247,9 @@ impl VestingContract { .get(&DataKey::ProposedAdmin) .unwrap_or_else(|| panic!("No proposed admin found")); proposed_admin.require_auth(); - env.storage().instance().set(&DataKey::AdminAddress, &proposed_admin); + env.storage() + .instance() + .set(&DataKey::AdminAddress, &proposed_admin); env.storage().instance().remove(&DataKey::ProposedAdmin); } @@ -337,15 +306,20 @@ impl VestingContract { ); } - env.events() - .publish(Symbol::new(&env, "ContractDeprecated"), v2_contract_address); + env.events().publish( + (Symbol::new(&env, "ContractDeprecated"),), + v2_contract_address, + ); migrated } // Get current admin address pub fn get_admin(env: Env) -> Address { - env.storage().instance().get(&DataKey::AdminAddress).unwrap_or_else(|| panic!("Admin not set")) + env.storage() + .instance() + .get(&DataKey::AdminAddress) + .unwrap_or_else(|| panic!("Admin not set")) } pub fn get_proposed_admin(env: Env) -> Option
{ @@ -355,15 +329,18 @@ impl VestingContract { // Toggle pause state (Admin only) - "Big Red Button" for emergency pause pub fn toggle_pause(env: Env) { Self::require_admin(&env); - - let current_pause_state: bool = env.storage() + + let current_pause_state: bool = env + .storage() .instance() .get(&DataKey::IsPaused) .unwrap_or(false); - + let new_pause_state = !current_pause_state; - env.storage().instance().set(&DataKey::IsPaused, &new_pause_state); - + env.storage() + .instance() + .set(&DataKey::IsPaused, &new_pause_state); + // Emit event for pause state change env.events().publish( (Symbol::new(&env, "PauseToggled"),), @@ -440,7 +417,6 @@ impl VestingContract { vault.is_frozen } - // Full initialization - writes all metadata immediately pub fn create_vault_full( env: Env, @@ -455,17 +431,30 @@ impl VestingContract { ) -> u64 { Self::require_admin(&env); - let mut vault_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let mut vault_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); vault_count += 1; - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); - if admin_balance < amount { panic!("Insufficient admin balance"); } + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); + if admin_balance < amount { + panic!("Insufficient admin balance"); + } admin_balance -= amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); let now = env.ledger().timestamp(); let vault = Vault { + title: String::from_slice(&env, ""), owner: owner.clone(), delegate: None, total_amount: amount, @@ -482,21 +471,46 @@ impl VestingContract { is_frozen: false, }; - env.storage().instance().set(&DataKey::VaultData(vault_count), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_count), &vault); - let mut user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(owner.clone())).unwrap_or(Vec::new(&env)); + let mut user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(owner.clone())) + .unwrap_or(Vec::new(&env)); user_vaults.push_back(vault_count); - env.storage().instance().set(&DataKey::UserVaults(owner.clone()), &user_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(owner.clone()), &user_vaults); - env.storage().instance().set(&DataKey::VaultCount, &vault_count); + env.storage() + .instance() + .set(&DataKey::VaultCount, &vault_count); - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); total_shares += amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let cliff_duration = start_time.saturating_sub(now); - let vault_created = VaultCreated { vault_id: vault_count, beneficiary: owner, total_amount: amount, cliff_duration, start_time }; - env.events().publish((Symbol::new(&env, "VaultCreated"), vault_count), vault_created); + let vault_created = VaultCreated { + vault_id: vault_count, + beneficiary: owner, + total_amount: amount, + cliff_duration, + start_time, + }; + env.events().publish( + (Symbol::new(&env, "VaultCreated"), vault_count), + vault_created, + ); vault_count } @@ -514,17 +528,30 @@ impl VestingContract { ) -> u64 { Self::require_admin(&env); - let mut vault_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let mut vault_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); vault_count += 1; - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); - if admin_balance < amount { panic!("Insufficient admin balance"); } + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); + if admin_balance < amount { + panic!("Insufficient admin balance"); + } admin_balance -= amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); let now = env.ledger().timestamp(); let vault = Vault { + title: String::from_slice(&env, ""), owner: owner.clone(), delegate: None, total_amount: amount, @@ -541,22 +568,46 @@ impl VestingContract { is_frozen: false, }; - env.storage().instance().set(&DataKey::VaultData(vault_count), &vault); - env.storage().instance().set(&DataKey::VaultCount, &vault_count); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_count), &vault); + env.storage() + .instance() + .set(&DataKey::VaultCount, &vault_count); - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); total_shares += amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let cliff_duration = start_time.saturating_sub(now); - let vault_created = VaultCreated { vault_id: vault_count, beneficiary: owner.clone(), total_amount: amount, cliff_duration, start_time }; - env.events().publish((Symbol::new(&env, "VaultCreated"), vault_count), vault_created); + let vault_created = VaultCreated { + vault_id: vault_count, + beneficiary: owner.clone(), + total_amount: amount, + cliff_duration, + start_time, + }; + env.events().publish( + (Symbol::new(&env, "VaultCreated"), vault_count), + vault_created, + ); vault_count } fn initialize_vault_metadata(env: &Env, vault_id: u64) -> bool { - if env.storage().instance().get(&DataKey::IsDeprecated).unwrap_or(false) { + if env + .storage() + .instance() + .get(&DataKey::IsDeprecated) + .unwrap_or(false) + { return false; } @@ -565,17 +616,24 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); if !vault.is_initialized { let mut updated_vault = vault.clone(); updated_vault.is_initialized = true; - env.storage().instance().set(&DataKey::VaultData(vault_id), &updated_vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &updated_vault); - let mut user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(updated_vault.owner.clone())).unwrap_or(Vec::new(env)); + let mut user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(updated_vault.owner.clone())) + .unwrap_or(Vec::new(env)); user_vaults.push_back(vault_id); - env.storage().instance().set(&DataKey::UserVaults(updated_vault.owner), &user_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(updated_vault.owner), &user_vaults); true } else { @@ -585,35 +643,25 @@ impl VestingContract { 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; } + 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; } + if duration == 0 { + return vault.total_amount; + } + let elapsed = now - vault.start_time; - let _effective_elapsed = if vault.step_duration > 0 { + let effective_elapsed = if vault.step_duration > 0 { (elapsed / vault.step_duration) * vault.step_duration } else { elapsed }; - if vault.step_duration > 0 { - // Periodic vesting: calculate vested = (elapsed / step_duration) * rate * step_duration - // Rate is total_amount / duration, so: vested = (elapsed / step_duration) * (total_amount / duration) * step_duration - // This simplifies to: vested = (elapsed / step_duration) * total_amount * step_duration / duration - let completed_steps = elapsed / vault.step_duration; - let rate_per_second = vault.total_amount / duration as i128; - 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 - } - } else { - // Linear vesting - (vault.total_amount * elapsed as i128) / duration as i128 - } (vault.total_amount * effective_elapsed as i128) / duration as i128 } @@ -635,12 +683,14 @@ impl VestingContract { if claim_amount <= 0 { panic!("Claim amount must be positive"); } - if !vault.is_initialized { panic!("Vault not initialized"); } - if claim_amount <= 0 { panic!("Claim amount must be positive"); } vault.owner.require_auth(); - let unlocked_amount = if env.storage().instance().has(&DataKey::VaultMilestones(vault_id)) { + let unlocked_amount = if env + .storage() + .instance() + .has(&DataKey::VaultMilestones(vault_id)) + { let milestones = Self::require_milestones_configured(&env, vault_id); let unlocked_pct = Self::unlocked_percentage(&milestones); Self::unlocked_amount(vault.total_amount, unlocked_pct) @@ -663,22 +713,44 @@ impl VestingContract { vault.staked_amount -= deficit; - let mut total_staked: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + let mut total_staked: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); total_staked -= deficit; - env.storage().instance().set(&DataKey::TotalStaked, &total_staked); + env.storage() + .instance() + .set(&DataKey::TotalStaked, &total_staked); } let available_to_claim = unlocked_amount - vault.released_amount; - if available_to_claim <= 0 { panic!("No tokens available to claim"); } - if claim_amount > available_to_claim { panic!("Insufficient unlocked tokens to claim"); } + if available_to_claim <= 0 { + panic!("No tokens available to claim"); + } + if claim_amount > available_to_claim { + panic!("Insufficient unlocked tokens to claim"); + } // YIELD DISTRIBUTION - only vault-owned portion let token_client = Self::get_token_client(&env); let current_balance = token_client.balance(&env.current_contract_address()); - let admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - let total_staked: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + let total_staked: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); let liquid_shares = total_shares - total_staked; let vault_portion = (current_balance - admin_balance).max(0); @@ -691,10 +763,18 @@ impl VestingContract { vault.released_amount += claim_amount; let mut updated_total_shares = total_shares; updated_total_shares -= claim_amount; - env.storage().instance().set(&DataKey::TotalShares, &updated_total_shares); - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::TotalShares, &updated_total_shares); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - token_client.transfer(&env.current_contract_address(), &vault.owner, &transfer_amount); + token_client.transfer( + &env.current_contract_address(), + &vault.owner, + &transfer_amount, + ); transfer_amount } @@ -702,29 +782,50 @@ impl VestingContract { pub fn transfer_beneficiary(env: Env, vault_id: u64, new_address: Address) { Self::require_admin(&env); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); let old_owner = vault.owner.clone(); if vault.is_initialized { - let old_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(old_owner.clone())).unwrap_or(Vec::new(&env)); + let old_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(old_owner.clone())) + .unwrap_or(Vec::new(&env)); let mut updated_old_vaults = Vec::new(&env); for id in old_vaults.iter() { if id != vault_id { updated_old_vaults.push_back(id); } } - env.storage().instance().set(&DataKey::UserVaults(old_owner.clone()), &updated_old_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(old_owner.clone()), &updated_old_vaults); - let mut new_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(new_address.clone())).unwrap_or(Vec::new(&env)); + let mut new_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(new_address.clone())) + .unwrap_or(Vec::new(&env)); new_vaults.push_back(vault_id); - env.storage().instance().set(&DataKey::UserVaults(new_address.clone()), &new_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(new_address.clone()), &new_vaults); } vault.owner = new_address.clone(); - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - env.events().publish((Symbol::new(&env, "BeneficiaryUpdated"), vault_id), (old_owner.clone(), new_address)); + env.events().publish( + (Symbol::new(&env, "BeneficiaryUpdated"), vault_id), + (old_owner.clone(), new_address), + ); } pub fn set_delegate(env: Env, vault_id: u64, delegate: Option
) { @@ -734,23 +835,27 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); - if !vault.is_initialized { panic!("Vault not initialized"); } + if !vault.is_initialized { + panic!("Vault not initialized"); + } vault.owner.require_auth(); let old_delegate = vault.delegate.clone(); vault.delegate = delegate.clone(); - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - env.events().publish((Symbol::new(&env, "DelegateUpdated"), vault_id), (old_delegate, delegate)); + env.events().publish( + (Symbol::new(&env, "DelegateUpdated"), vault_id), + (old_delegate, delegate), + ); } pub fn claim_as_delegate(env: Env, vault_id: u64, claim_amount: i128) -> i128 { - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); - let vault: Vault = env .storage() .instance() @@ -768,26 +873,51 @@ impl VestingContract { if claim_amount <= 0 { panic!("Claim amount must be positive"); } - if !vault.is_initialized { panic!("Vault not initialized"); } - if claim_amount <= 0 { panic!("Claim amount must be positive"); } - let delegate = vault.delegate.clone().unwrap_or_else(|| panic!("No delegate set for this vault")); + let delegate = vault + .delegate + .clone() + .unwrap_or_else(|| panic!("No delegate set for this vault")); delegate.require_auth(); - let milestones = Self::require_milestones_configured(&env, vault_id); - let unlocked_pct = Self::unlocked_percentage(&milestones); - let unlocked_amount = Self::unlocked_amount(vault.total_amount, unlocked_pct); + let unlocked_amount = if env + .storage() + .instance() + .has(&DataKey::VaultMilestones(vault_id)) + { + let milestones = Self::require_milestones_configured(&env, vault_id); + let unlocked_pct = Self::unlocked_percentage(&milestones); + Self::unlocked_amount(vault.total_amount, unlocked_pct) + } else { + Self::calculate_time_vested_amount(&env, &vault) + }; let available_to_claim = unlocked_amount - vault.released_amount; - if available_to_claim <= 0 { panic!("No tokens available to claim"); } - if claim_amount > available_to_claim { panic!("Insufficient unlocked tokens to claim"); } + if available_to_claim <= 0 { + panic!("No tokens available to claim"); + } + if claim_amount > available_to_claim { + panic!("Insufficient unlocked tokens to claim"); + } // YIELD DISTRIBUTION - only vault-owned portion let token_client = Self::get_token_client(&env); let current_balance = token_client.balance(&env.current_contract_address()); - let admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - let total_staked: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + let total_staked: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); let liquid_shares = total_shares - total_staked; let vault_portion = (current_balance - admin_balance).max(0); @@ -802,10 +932,18 @@ impl VestingContract { let mut updated_total_shares = total_shares; updated_total_shares -= claim_amount; - env.storage().instance().set(&DataKey::TotalShares, &updated_total_shares); - env.storage().instance().set(&DataKey::VaultData(vault_id), &updated_vault); + env.storage() + .instance() + .set(&DataKey::TotalShares, &updated_total_shares); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &updated_vault); - token_client.transfer(&env.current_contract_address(), &updated_vault.owner, &transfer_amount); + token_client.transfer( + &env.current_contract_address(), + &updated_vault.owner, + &transfer_amount, + ); transfer_amount } @@ -813,34 +951,62 @@ impl VestingContract { pub fn set_milestones(env: Env, vault_id: u64, milestones: Vec) { Self::require_admin(&env); - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); - if !vault.is_initialized { panic!("Vault not initialized"); } + let vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); + if !vault.is_initialized { + panic!("Vault not initialized"); + } - if milestones.is_empty() { panic!("No milestones provided"); } + if milestones.is_empty() { + panic!("No milestones provided"); + } let mut total_pct: u32 = 0; let mut seen: Map = Map::new(&env); for m in milestones.iter() { - if m.percentage == 0 { panic!("Milestone percentage must be positive"); } - if m.percentage > 100 { panic!("Milestone percentage too large"); } - if seen.contains_key(m.id) { panic!("Duplicate milestone id"); } + if m.percentage == 0 { + panic!("Milestone percentage must be positive"); + } + if m.percentage > 100 { + panic!("Milestone percentage too large"); + } + if seen.contains_key(m.id) { + panic!("Duplicate milestone id"); + } seen.set(m.id, true); total_pct = total_pct.saturating_add(m.percentage); } - if total_pct > 100 { panic!("Total milestone percentage exceeds 100"); } + if total_pct > 100 { + panic!("Total milestone percentage exceeds 100"); + } - env.storage().instance().set(&DataKey::VaultMilestones(vault_id), &milestones); - env.events().publish((Symbol::new(&env, "MilestonesSet"), vault_id), (milestones.len(), total_pct)); + env.storage() + .instance() + .set(&DataKey::VaultMilestones(vault_id), &milestones); + env.events().publish( + (Symbol::new(&env, "MilestonesSet"), vault_id), + (milestones.len(), total_pct), + ); } pub fn get_milestones(env: Env, vault_id: u64) -> Vec { - env.storage().instance().get(&DataKey::VaultMilestones(vault_id)).unwrap_or(Vec::new(&env)) + env.storage() + .instance() + .get(&DataKey::VaultMilestones(vault_id)) + .unwrap_or(Vec::new(&env)) } pub fn unlock_milestone(env: Env, vault_id: u64, milestone_id: u64) { Self::require_admin(&env); - let _vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let _vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); let milestones = Self::require_milestones_configured(&env, vault_id); @@ -849,36 +1015,62 @@ impl VestingContract { for m in milestones.iter() { if m.id == milestone_id { found = true; - if m.is_unlocked { panic!("Milestone already unlocked"); } - updated.push_back(Milestone { id: m.id, percentage: m.percentage, is_unlocked: true }); + if m.is_unlocked { + panic!("Milestone already unlocked"); + } + updated.push_back(Milestone { + id: m.id, + percentage: m.percentage, + is_unlocked: true, + }); } else { updated.push_back(m); } } - if !found { panic!("Milestone not found"); } + if !found { + panic!("Milestone not found"); + } - env.storage().instance().set(&DataKey::VaultMilestones(vault_id), &updated); + env.storage() + .instance() + .set(&DataKey::VaultMilestones(vault_id), &updated); let timestamp = env.ledger().timestamp(); - env.events().publish((Symbol::new(&env, "MilestoneUnlocked"), vault_id), (milestone_id, timestamp)); + env.events().publish( + (Symbol::new(&env, "MilestoneUnlocked"), vault_id), + (milestone_id, timestamp), + ); } pub fn batch_create_vaults_lazy(env: Env, batch_data: BatchCreateData) -> Vec { Self::require_admin(&env); let mut vault_ids = Vec::new(&env); - let initial_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let initial_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); let total_amount: i128 = batch_data.amounts.iter().sum(); - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); - if admin_balance < total_amount { panic!("Insufficient admin balance for batch"); } + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); + if admin_balance < total_amount { + panic!("Insufficient admin balance for batch"); + } admin_balance -= total_amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); let now = env.ledger().timestamp(); for i in 0..batch_data.recipients.len() { let vault_id = initial_count + i as u64 + 1; let vault = Vault { + title: String::from_slice(&env, ""), owner: batch_data.recipients.get(i).unwrap(), delegate: None, total_amount: batch_data.amounts.get(i).unwrap(), @@ -886,9 +1078,6 @@ impl VestingContract { start_time: batch_data.start_times.get(i).unwrap(), end_time: batch_data.end_times.get(i).unwrap(), keeper_fee: batch_data.keeper_fees.get(i).unwrap(), - title: String::from_slice(&env, ""), - is_initialized: false, // Lazy initialization - is_irrevocable: false, // Default to revocable for batch operations is_initialized: false, is_irrevocable: false, creation_time: now, @@ -898,21 +1087,38 @@ impl VestingContract { is_frozen: false, }; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); vault_ids.push_back(vault_id); let start_time = batch_data.start_times.get(i).unwrap(); let cliff_duration = start_time.saturating_sub(now); - let vault_created = VaultCreated { vault_id, beneficiary: vault.owner.clone(), total_amount: vault.total_amount, cliff_duration, start_time }; - env.events().publish((Symbol::new(&env, "VaultCreated"), vault_id), vault_created); + let vault_created = VaultCreated { + vault_id, + beneficiary: vault.owner.clone(), + total_amount: vault.total_amount, + cliff_duration, + start_time, + }; + env.events() + .publish((Symbol::new(&env, "VaultCreated"), vault_id), vault_created); } - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); total_shares += total_amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let final_count = initial_count + batch_data.recipients.len() as u64; - env.storage().instance().set(&DataKey::VaultCount, &final_count); + env.storage() + .instance() + .set(&DataKey::VaultCount, &final_count); vault_ids } @@ -921,19 +1127,32 @@ impl VestingContract { Self::require_admin(&env); let mut vault_ids = Vec::new(&env); - let initial_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let initial_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); let total_amount: i128 = batch_data.amounts.iter().sum(); - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); - if admin_balance < total_amount { panic!("Insufficient admin balance for batch"); } - admin_balance -= total_amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); + if admin_balance < total_amount { + panic!("Insufficient admin balance for batch"); + } + admin_balance -= total_amount; + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); let now = env.ledger().timestamp(); for i in 0..batch_data.recipients.len() { let vault_id = initial_count + i as u64 + 1; let vault = Vault { + title: String::from_slice(&env, ""), owner: batch_data.recipients.get(i).unwrap(), delegate: None, total_amount: batch_data.amounts.get(i).unwrap(), @@ -947,29 +1166,52 @@ impl VestingContract { is_transferable: false, step_duration: batch_data.step_durations.get(i).unwrap_or(0), staked_amount: 0, - is_frozen: false, + is_frozen: false, }; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - let mut user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(vault.owner.clone())).unwrap_or(Vec::new(&env)); + let mut user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(vault.owner.clone())) + .unwrap_or(Vec::new(&env)); user_vaults.push_back(vault_id); - env.storage().instance().set(&DataKey::UserVaults(vault.owner.clone()), &user_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(vault.owner.clone()), &user_vaults); vault_ids.push_back(vault_id); let start_time = batch_data.start_times.get(i).unwrap(); let cliff_duration = start_time.saturating_sub(now); - let vault_created = VaultCreated { vault_id, beneficiary: vault.owner.clone(), total_amount: vault.total_amount, cliff_duration, start_time }; - env.events().publish((Symbol::new(&env, "VaultCreated"), vault_id), vault_created); + let vault_created = VaultCreated { + vault_id, + beneficiary: vault.owner.clone(), + total_amount: vault.total_amount, + cliff_duration, + start_time, + }; + env.events() + .publish((Symbol::new(&env, "VaultCreated"), vault_id), vault_created); } - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); total_shares += total_amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let final_count = initial_count + batch_data.recipients.len() as u64; - env.storage().instance().set(&DataKey::VaultCount, &final_count); + env.storage() + .instance() + .set(&DataKey::VaultCount, &final_count); vault_ids } @@ -980,18 +1222,24 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); if !vault.is_initialized { Self::initialize_vault_metadata(&env, vault_id); - env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap() + env.storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")) } else { vault } } pub fn get_user_vaults(env: Env, user: Address) -> Vec { - let vault_ids: Vec = env.storage().instance().get(&DataKey::UserVaults(user.clone())).unwrap_or(Vec::new(&env)); + let vault_ids: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(user)) + .unwrap_or(Vec::new(&env)); for vault_id in vault_ids.iter() { let vault: Vault = env @@ -999,7 +1247,6 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); if !vault.is_initialized { Self::initialize_vault_metadata(&env, vault_id); @@ -1012,30 +1259,26 @@ impl VestingContract { pub fn revoke_tokens(env: Env, vault_id: u64) -> i128 { Self::require_admin(&env); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); - if vault.is_irrevocable { panic!("Vault is irrevocable"); } + if vault.is_irrevocable { + panic!("Vault is irrevocable"); + } - let unreleased_amount = vault.total_amount - vault.released_amount; - if unreleased_amount <= 0 { panic!("No tokens available to revoke"); } + let returned = vault.total_amount - vault.released_amount; + if returned <= 0 { + panic!("No tokens available to revoke"); + } vault.released_amount = vault.total_amount; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); - - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); - admin_balance += unreleased_amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); - - unreleased_amount - } - - // Admin-only: Revoke tokens from a vault and return them to admin - pub fn revoke_tokens(env: Env, vault_id: u64) -> i128 { - Self::require_admin(&env); - - let returned = Self::internal_revoke_full(&env, vault_id); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - // Single admin balance update for this call let mut admin_balance: i128 = env .storage() .instance() @@ -1045,10 +1288,16 @@ impl VestingContract { env.storage() .instance() .set(&DataKey::AdminBalance, &admin_balance); - returned - 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); + + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + total_shares -= returned; + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let timestamp = env.ledger().timestamp(); env.events().publish( @@ -1057,28 +1306,59 @@ impl VestingContract { ); returned - env.events().publish((Symbol::new(&env, "TokensRevoked"), vault_id), (unreleased_amount, timestamp)); - - unreleased_amount } pub fn revoke_partial(env: Env, vault_id: u64, amount: i128) -> i128 { Self::require_admin(&env); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); - if vault.is_irrevocable { panic!("Vault is irrevocable"); } + if vault.is_irrevocable { + panic!("Vault is irrevocable"); + } let unvested_balance = vault.total_amount - vault.released_amount; - if amount <= 0 { panic!("Amount to revoke must be positive"); } - if amount > unvested_balance { panic!("Amount exceeds unvested balance"); } + if amount <= 0 { + panic!("Amount to revoke must be positive"); + } + if amount > unvested_balance { + panic!("Amount exceeds unvested balance"); + } vault.released_amount += amount; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); admin_balance += amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); + + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + total_shares -= amount; + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); + + let timestamp = env.ledger().timestamp(); + env.events().publish( + (Symbol::new(&env, "TokensRevoked"), vault_id), + (amount, timestamp), + ); amount } @@ -1088,12 +1368,35 @@ impl VestingContract { Self::require_admin(&env); let mut total_returned: i128 = 0; - for id in vault_ids.iter() { - let returned = Self::internal_revoke_full(&env, id); + for vault_id in vault_ids.iter() { + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); + + if vault.is_irrevocable { + panic!("Vault is irrevocable"); + } + + let returned = vault.total_amount - vault.released_amount; + if returned <= 0 { + continue; + } + + vault.released_amount = vault.total_amount; + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); total_returned += returned; + + let timestamp = env.ledger().timestamp(); + env.events().publish( + (Symbol::new(&env, "TokensRevoked"), vault_id), + (returned, timestamp), + ); } - // Single admin balance update for the whole batch let mut admin_balance: i128 = env .storage() .instance() @@ -1103,61 +1406,101 @@ impl VestingContract { env.storage() .instance() .set(&DataKey::AdminBalance, &admin_balance); - total_returned - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - total_shares -= amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + total_shares -= total_returned; + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); let timestamp = env.ledger().timestamp(); env.events().publish( - (Symbol::new(&env, "BatchRevoked"), vault_ids.len()), - (total_returned, timestamp), + (Symbol::new(&env, "BatchRevoked"),), + (vault_ids.len(), total_returned, timestamp), ); total_returned - env.events().publish((Symbol::new(&env, "TokensRevoked"), vault_id), (amount, timestamp)); - - amount } pub fn clawback_vault(env: Env, vault_id: u64) -> i128 { Self::require_admin(&env); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); let now = env.ledger().timestamp(); let grace_period = 3600u64; - if now > vault.creation_time + grace_period { panic!("Grace period expired"); } - if vault.released_amount > 0 { panic!("Tokens already claimed"); } + if now > vault.creation_time + grace_period { + panic!("Grace period expired"); + } + if vault.released_amount > 0 { + panic!("Tokens already claimed"); + } - let mut admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let mut admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); admin_balance += vault.total_amount; - env.storage().instance().set(&DataKey::AdminBalance, &admin_balance); + env.storage() + .instance() + .set(&DataKey::AdminBalance, &admin_balance); vault.released_amount = vault.total_amount; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - let mut total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + let mut total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); total_shares -= vault.total_amount; - env.storage().instance().set(&DataKey::TotalShares, &total_shares); + env.storage() + .instance() + .set(&DataKey::TotalShares, &total_shares); - env.events().publish((Symbol::new(&env, "VaultClawedBack"), vault_id), vault.total_amount); + env.events().publish( + (Symbol::new(&env, "VaultClawedBack"), vault_id), + vault.total_amount, + ); vault.total_amount } pub fn transfer_vault(env: Env, vault_id: u64, new_beneficiary: Address) { - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); - if !vault.is_initialized { panic!("Vault not initialized"); } - if !vault.is_transferable { panic!("Vault is non-transferable"); } + if !vault.is_initialized { + panic!("Vault not initialized"); + } + if !vault.is_transferable { + panic!("Vault is non-transferable"); + } vault.owner.require_auth(); let old_owner = vault.owner.clone(); - let old_user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(old_owner.clone())).unwrap_or(Vec::new(&env)); + let old_user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(old_owner.clone())) + .unwrap_or(Vec::new(&env)); let mut new_old_user_vaults = Vec::new(&env); for id in old_user_vaults.iter() { @@ -1165,29 +1508,54 @@ impl VestingContract { new_old_user_vaults.push_back(id); } } - env.storage().instance().set(&DataKey::UserVaults(old_owner.clone()), &new_old_user_vaults); + env.storage().instance().set( + &DataKey::UserVaults(old_owner.clone()), + &new_old_user_vaults, + ); - let mut new_user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(new_beneficiary.clone())).unwrap_or(Vec::new(&env)); + let mut new_user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(new_beneficiary.clone())) + .unwrap_or(Vec::new(&env)); new_user_vaults.push_back(vault_id); - env.storage().instance().set(&DataKey::UserVaults(new_beneficiary.clone()), &new_user_vaults); + env.storage().instance().set( + &DataKey::UserVaults(new_beneficiary.clone()), + &new_user_vaults, + ); vault.owner = new_beneficiary.clone(); vault.delegate = None; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - env.events().publish((Symbol::new(&env, "BeneficiaryUpdated"), vault_id), (old_owner, new_beneficiary)); + env.events().publish( + (Symbol::new(&env, "BeneficiaryUpdated"), vault_id), + (old_owner, new_beneficiary), + ); } pub fn rotate_beneficiary_key(env: Env, vault_id: u64, new_address: Address) { - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); - if !vault.is_initialized { panic!("Vault not initialized"); } + if !vault.is_initialized { + panic!("Vault not initialized"); + } vault.owner.require_auth(); let old_owner = vault.owner.clone(); - let old_user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(old_owner.clone())).unwrap_or(Vec::new(&env)); + let old_user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(old_owner.clone())) + .unwrap_or(Vec::new(&env)); let mut new_old_user_vaults = Vec::new(&env); for id in old_user_vaults.iter() { @@ -1195,22 +1563,38 @@ impl VestingContract { new_old_user_vaults.push_back(id); } } - env.storage().instance().set(&DataKey::UserVaults(old_owner.clone()), &new_old_user_vaults); + env.storage().instance().set( + &DataKey::UserVaults(old_owner.clone()), + &new_old_user_vaults, + ); - let mut new_user_vaults: Vec = env.storage().instance().get(&DataKey::UserVaults(new_address.clone())).unwrap_or(Vec::new(&env)); + let mut new_user_vaults: Vec = env + .storage() + .instance() + .get(&DataKey::UserVaults(new_address.clone())) + .unwrap_or(Vec::new(&env)); new_user_vaults.push_back(vault_id); - env.storage().instance().set(&DataKey::UserVaults(new_address.clone()), &new_user_vaults); + env.storage() + .instance() + .set(&DataKey::UserVaults(new_address.clone()), &new_user_vaults); vault.owner = new_address.clone(); vault.delegate = None; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); - env.events().publish((Symbol::new(&env, "BeneficiaryRotated"), vault_id), (old_owner, new_address)); + env.events().publish( + (Symbol::new(&env, "BeneficiaryRotated"), vault_id), + (old_owner, new_address), + ); } pub fn set_staking_contract(env: Env, contract: Address) { Self::require_admin(&env); - env.storage().instance().set(&Symbol::new(&env, "StakingContract"), &contract); + env.storage() + .instance() + .set(&Symbol::new(&env, "StakingContract"), &contract); } pub fn stake_tokens(env: Env, vault_id: u64, amount: i128, validator: Address) { @@ -1220,58 +1604,107 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); - if !vault.is_initialized { panic!("Vault not initialized"); } + if !vault.is_initialized { + panic!("Vault not initialized"); + } vault.owner.require_auth(); let available = vault.total_amount - vault.released_amount - vault.staked_amount; - if amount <= 0 { panic!("Amount must be positive"); } - if amount > available { panic!("Insufficient funds to stake"); } - - let staking_contract: Address = env.storage().instance().get(&Symbol::new(&env, "StakingContract")).expect("Staking contract not set"); + if amount <= 0 { + panic!("Amount must be positive"); + } + if amount > available { + panic!("Insufficient funds to stake"); + } - let args = vec![&env, vault_id.into_val(&env), amount.into_val(&env), validator.into_val(&env)]; + let staking_contract: Address = env + .storage() + .instance() + .get(&Symbol::new(&env, "StakingContract")) + .expect("Staking contract not set"); + + let args = vec![ + &env, + vault_id.into_val(&env), + amount.into_val(&env), + validator.into_val(&env), + ]; env.invoke_contract::<()>(&staking_contract, &Symbol::new(&env, "stake"), args); vault.staked_amount += amount; - let mut total_staked: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + let mut total_staked: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); total_staked += amount; - env.storage().instance().set(&DataKey::TotalStaked, &total_staked); + env.storage() + .instance() + .set(&DataKey::TotalStaked, &total_staked); - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); } pub fn mark_irrevocable(env: Env, vault_id: u64) { Self::require_admin(&env); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let mut vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); - if vault.is_irrevocable { panic!("Vault is already irrevocable"); } + if vault.is_irrevocable { + panic!("Vault is already irrevocable"); + } vault.is_irrevocable = true; - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); + env.storage() + .instance() + .set(&DataKey::VaultData(vault_id), &vault); let timestamp = env.ledger().timestamp(); - env.events().publish((Symbol::new(&env, "IrrevocableMarked"), vault_id), timestamp); + env.events().publish( + (Symbol::new(&env, "IrrevocableMarked"), vault_id), + timestamp, + ); } pub fn is_vault_irrevocable(env: Env, vault_id: u64) -> bool { - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); + let vault: Vault = env + .storage() + .instance() + .get(&DataKey::VaultData(vault_id)) + .unwrap_or_else(|| panic!("Vault not found")); vault.is_irrevocable } pub fn get_contract_state(env: Env) -> (i128, i128, i128) { - let admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); - let vault_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let vault_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); let mut total_locked = 0i128; let mut total_claimed = 0i128; for i in 1..=vault_count { - if let Some(vault) = env.storage().instance().get::(&DataKey::VaultData(i)) { + if let Some(vault) = env + .storage() + .instance() + .get::(&DataKey::VaultData(i)) + { total_locked += vault.total_amount - vault.released_amount; total_claimed += vault.released_amount; } @@ -1281,7 +1714,11 @@ impl VestingContract { } pub fn check_invariant(env: Env) -> bool { - let initial_supply: i128 = env.storage().instance().get(&DataKey::InitialSupply).unwrap_or(0); + let initial_supply: i128 = env + .storage() + .instance() + .get(&DataKey::InitialSupply) + .unwrap_or(0); let (total_locked, _total_claimed, admin_balance) = Self::get_contract_state(env); let net_paid_out = initial_supply - total_locked - admin_balance; @@ -1289,13 +1726,11 @@ impl VestingContract { } pub fn get_claimable_amount(env: Env, vault_id: u64) -> i128 { - let vault: Vault = env.storage().instance() let vault: Vault = env .storage() .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); let vested = Self::calculate_time_vested_amount(&env, &vault); @@ -1307,7 +1742,6 @@ impl VestingContract { } pub fn auto_claim(env: Env, vault_id: u64, keeper: Address) { - let mut vault: Vault = env.storage().instance() if Self::is_paused(env.clone()) { panic!("Contract is paused - all withdrawals are disabled"); } @@ -1317,7 +1751,6 @@ impl VestingContract { .instance() .get(&DataKey::VaultData(vault_id)) .unwrap_or_else(|| panic!("Vault not found")); - let mut vault: Vault = env.storage().instance().get(&DataKey::VaultData(vault_id)).unwrap_or_else(|| panic!("Vault not found")); // Check if vault is frozen if vault.is_frozen { @@ -1327,11 +1760,12 @@ impl VestingContract { if !vault.is_initialized { panic!("Vault not initialized"); } - if !vault.is_initialized { panic!("Vault not initialized"); } let claimable = Self::get_claimable_amount(env.clone(), vault_id); - if claimable <= vault.keeper_fee { panic!("Insufficient claimable tokens to cover fee"); } + if claimable <= vault.keeper_fee { + panic!("Insufficient claimable tokens to cover fee"); + } let beneficiary_amount = claimable - vault.keeper_fee; let keeper_fee = vault.keeper_fee; @@ -1339,10 +1773,22 @@ impl VestingContract { // YIELD DISTRIBUTION - only vault-owned portion let token_client = Self::get_token_client(&env); let current_balance = token_client.balance(&env.current_contract_address()); - let admin_balance: i128 = env.storage().instance().get(&DataKey::AdminBalance).unwrap_or(0); + let admin_balance: i128 = env + .storage() + .instance() + .get(&DataKey::AdminBalance) + .unwrap_or(0); - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - let total_staked: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(0); + let total_staked: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); let liquid_shares = total_shares - total_staked; let vault_portion = (current_balance - admin_balance).max(0); @@ -1359,27 +1805,43 @@ impl VestingContract { }; vault.released_amount += claimable; + let mut updated_total_shares = total_shares; + updated_total_shares -= claimable; + env.storage() + .instance() + .set(&DataKey::TotalShares, &updated_total_shares); env.storage() .instance() .set(&DataKey::VaultData(vault_id), &vault); - let mut updated_total_shares = total_shares; - updated_total_shares -= claimable; - env.storage().instance().set(&DataKey::TotalShares, &updated_total_shares); - env.storage().instance().set(&DataKey::VaultData(vault_id), &vault); - token_client.transfer(&env.current_contract_address(), &vault.owner, &beneficiary_tokens); + token_client.transfer( + &env.current_contract_address(), + &vault.owner, + &beneficiary_tokens, + ); token_client.transfer(&env.current_contract_address(), &keeper, &keeper_tokens); - let mut fees: Map = env.storage().instance().get(&DataKey::KeeperFees).unwrap_or(Map::new(&env)); + let mut fees: Map = env + .storage() + .instance() + .get(&DataKey::KeeperFees) + .unwrap_or(Map::new(&env)); let current_fees = fees.get(keeper.clone()).unwrap_or(0); fees.set(keeper.clone(), current_fees + keeper_fee); env.storage().instance().set(&DataKey::KeeperFees, &fees); - env.events().publish((Symbol::new(&env, "KeeperClaim"), vault_id), (keeper, beneficiary_amount, keeper_fee)); + env.events().publish( + (Symbol::new(&env, "KeeperClaim"), vault_id), + (keeper, beneficiary_amount, keeper_fee), + ); } pub fn get_keeper_fee(env: Env, keeper: Address) -> i128 { - let fees: Map = env.storage().instance().get(&DataKey::KeeperFees).unwrap_or(Map::new(&env)); + let fees: Map = env + .storage() + .instance() + .get(&DataKey::KeeperFees) + .unwrap_or(Map::new(&env)); fees.get(keeper).unwrap_or(0) } @@ -1399,11 +1861,19 @@ impl VestingContract { } } - let vault_count: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + let vault_count: u64 = env + .storage() + .instance() + .get(&DataKey::VaultCount) + .unwrap_or(0); let mut total_liabilities: i128 = 0; for i in 1..=vault_count { - if let Some(vault) = env.storage().instance().get::(&DataKey::VaultData(i)) { + if let Some(vault) = env + .storage() + .instance() + .get::(&DataKey::VaultData(i)) + { let unreleased = vault.total_amount - vault.released_amount; if unreleased > 0 { total_liabilities += unreleased; @@ -1417,15 +1887,23 @@ impl VestingContract { panic!("No unallocated tokens to rescue"); } - let admin: Address = env.storage().instance().get(&DataKey::AdminAddress).unwrap_or_else(|| panic!("Admin not set")); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::AdminAddress) + .unwrap_or_else(|| panic!("Admin not set")); - token_client.transfer(&env.current_contract_address(), &admin, &unallocated_balance); + token_client.transfer( + &env.current_contract_address(), + &admin, + &unallocated_balance, + ); - env.events().publish((Symbol::new(&env, "RescueExecuted"), token_address), (unallocated_balance, admin)); + env.events().publish( + (Symbol::new(&env, "RescueExecuted"), token_address), + (unallocated_balance, admin), + ); unallocated_balance } } - -// // mod test; // Disabled - tests need refactoring -mod test; diff --git a/contracts/vesting_contracts/tests/multisig_admin_threshold.rs b/contracts/vesting_contracts/tests/multisig_admin_threshold.rs new file mode 100644 index 0000000..df39f21 --- /dev/null +++ b/contracts/vesting_contracts/tests/multisig_admin_threshold.rs @@ -0,0 +1,273 @@ +use soroban_sdk::auth::{Context, CustomAccountInterface}; +use soroban_sdk::crypto::Hash; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::xdr; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, Map, Symbol, Val, + Vec, +}; + +use vesting_contracts::{VestingContract, VestingContractClient}; + +#[contract] +struct MultisigAccount; + +#[contracttype] +enum MultisigDataKey { + Signers, + Threshold, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum MultisigError { + ThresholdNotMet = 1, + InvalidContext = 2, +} + +#[contractimpl] +impl MultisigAccount { + pub fn init(env: Env, signers: Vec
, threshold: u32) { + env.storage() + .instance() + .set(&MultisigDataKey::Signers, &signers); + env.storage() + .instance() + .set(&MultisigDataKey::Threshold, &threshold); + } +} + +#[contractimpl] +impl CustomAccountInterface for MultisigAccount { + type Signature = Vec
; + type Error = MultisigError; + + fn __check_auth( + env: Env, + _signature_payload: Hash<32>, + signatures: Vec
, + auth_contexts: Vec, + ) -> Result<(), Self::Error> { + let allowed: Vec
= env + .storage() + .instance() + .get(&MultisigDataKey::Signers) + .unwrap_or(Vec::new(&env)); + let threshold: u32 = env + .storage() + .instance() + .get(&MultisigDataKey::Threshold) + .unwrap_or(0); + + let mut allowed_map: Map = Map::new(&env); + for addr in allowed.iter() { + allowed_map.set(addr, true); + } + + let mut seen: Map = Map::new(&env); + let mut approvals: u32 = 0; + for signer in signatures.iter() { + if allowed_map.get(signer.clone()).unwrap_or(false) + && !seen.get(signer.clone()).unwrap_or(false) + { + seen.set(signer.clone(), true); + approvals += 1; + } + } + if approvals < threshold { + return Err(MultisigError::ThresholdNotMet); + } + + // Ensure we were asked to authorize a create_vault_full call. + let expected_fn = Symbol::new(&env, "create_vault_full"); + let mut has_expected_context = false; + for ctx in auth_contexts.iter() { + if let Context::Contract(contract_ctx) = ctx { + if contract_ctx.fn_name == expected_fn { + has_expected_context = true; + break; + } + } + } + if !has_expected_context { + return Err(MultisigError::InvalidContext); + } + + Ok(()) + } +} + +fn auth_entry_for_multisig( + env: &Env, + authorizer: &Address, + contract: &Address, + fn_name: &str, + args: Vec, + signature: xdr::ScVal, + nonce: i64, +) -> xdr::SorobanAuthorizationEntry { + let root_invocation = xdr::SorobanAuthorizedInvocation { + function: xdr::SorobanAuthorizedFunction::ContractFn(xdr::InvokeContractArgs { + contract_address: contract.clone().try_into().unwrap(), + function_name: fn_name.try_into().unwrap(), + args: args.try_into().unwrap(), + }), + sub_invocations: std::vec::Vec::::new() + .try_into() + .unwrap(), + }; + + xdr::SorobanAuthorizationEntry { + root_invocation, + credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials { + address: authorizer.try_into().unwrap(), + nonce, + signature_expiration_ledger: env.ledger().sequence() + 1000, + signature, + }), + } +} + +fn signatures_scval(signers: &[Address]) -> xdr::ScVal { + let mut sig_vals: std::vec::Vec = std::vec::Vec::with_capacity(signers.len()); + for signer in signers { + sig_vals.push(xdr::ScVal::Address(signer.try_into().unwrap())); + } + xdr::ScVal::Vec(Some(sig_vals.try_into().unwrap())) +} + +#[test] +fn create_vault_succeeds_with_multisig_admin_threshold_met() { + let env = Env::default(); + env.ledger().set_sequence_number(1); + env.ledger().set_timestamp(1_000); + + // Multisig admin custom account. + let multisig_id = env.register(MultisigAccount, ()); + let multisig_client = MultisigAccountClient::new(&env, &multisig_id); + + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + let s3 = Address::generate(&env); + let mut signers = Vec::new(&env); + signers.push_back(s1.clone()); + signers.push_back(s2.clone()); + signers.push_back(s3.clone()); + multisig_client.init(&signers, &2u32); + + // Vesting contract with multisig as admin. + let vesting_id = env.register(VestingContract, ()); + let vesting = VestingContractClient::new(&env, &vesting_id); + vesting.initialize(&multisig_id, &1_000_000i128); + + let beneficiary = Address::generate(&env); + let now = env.ledger().timestamp(); + let amount = 1_000i128; + let keeper_fee = 0i128; + let start = now; + let end = now + 1_000; + let is_revocable = true; + let is_transferable = false; + let step_duration = 0u64; + + // Provide an authorization entry for the multisig admin where signatures contain >= threshold signers. + let args: Vec = ( + beneficiary.clone(), + amount, + start, + end, + keeper_fee, + is_revocable, + is_transferable, + step_duration, + ) + .into_val(&env); + let entry = auth_entry_for_multisig( + &env, + &multisig_id, + &vesting_id, + "create_vault_full", + args, + signatures_scval(&[s1.clone(), s2.clone()]), + 1, + ); + env.set_auths(&[entry]); + + let vault_id = vesting.create_vault_full( + &beneficiary, + &amount, + &start, + &end, + &keeper_fee, + &is_revocable, + &is_transferable, + &step_duration, + ); + assert_eq!(vault_id, 1u64); +} + +#[test] +#[should_panic] +fn create_vault_panics_when_multisig_threshold_not_met() { + let env = Env::default(); + env.ledger().set_sequence_number(1); + env.ledger().set_timestamp(1_000); + + let multisig_id = env.register(MultisigAccount, ()); + let multisig_client = MultisigAccountClient::new(&env, &multisig_id); + + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + let mut signers = Vec::new(&env); + signers.push_back(s1.clone()); + signers.push_back(s2.clone()); + multisig_client.init(&signers, &2u32); + + let vesting_id = env.register(VestingContract, ()); + let vesting = VestingContractClient::new(&env, &vesting_id); + vesting.initialize(&multisig_id, &1_000_000i128); + + let beneficiary = Address::generate(&env); + let now = env.ledger().timestamp(); + let amount = 1_000i128; + let keeper_fee = 0i128; + let start = now; + let end = now + 1_000; + let is_revocable = true; + let is_transferable = false; + let step_duration = 0u64; + + // Only one signer provided, but threshold is 2. + let args: Vec = ( + beneficiary.clone(), + amount, + start, + end, + keeper_fee, + is_revocable, + is_transferable, + step_duration, + ) + .into_val(&env); + let entry = auth_entry_for_multisig( + &env, + &multisig_id, + &vesting_id, + "create_vault_full", + args, + signatures_scval(&[s1.clone()]), + 1, + ); + env.set_auths(&[entry]); + + vesting.create_vault_full( + &beneficiary, + &amount, + &start, + &end, + &keeper_fee, + &is_revocable, + &is_transferable, + &step_duration, + ); +}