diff --git a/contracts/stake-vault/src/lib.rs b/contracts/stake-vault/src/lib.rs index 756b617..be17bbf 100644 --- a/contracts/stake-vault/src/lib.rs +++ b/contracts/stake-vault/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] -use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env}; +use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Vec}; pub mod types; -use types::{DataKey, StakeInfo}; +use types::{default_multiplier_tiers, DataKey, MultiplierTier, StakeInfo}; #[contract] pub struct StakeVault; @@ -31,6 +31,13 @@ pub struct Unstaked { pub amount: i128, } +#[contractevent] +pub struct MultiplierTiersUpdated { + #[topic] + pub admin: Address, + pub tiers: Vec, +} + #[contractevent] pub struct ContractUpgraded { #[topic] @@ -38,6 +45,24 @@ pub struct ContractUpgraded { pub new_wasm_hash: BytesN<32>, } +fn validate_tiers(tiers: &Vec) { + let mut prev_min_stake: Option = None; + for tier in tiers.iter() { + if tier.min_stake <= 0 { + panic!("Tier min_stake must be positive"); + } + if tier.multiplier == 0 { + panic!("Tier multiplier must be non-zero"); + } + if let Some(prev) = prev_min_stake { + if tier.min_stake >= prev { + panic!("Tier min_stake must be strictly descending"); + } + } + prev_min_stake = Some(tier.min_stake); + } +} + #[contractimpl] impl StakeVault { pub fn initialize(env: Env, admin: Address, token: Address) { @@ -50,9 +75,42 @@ impl StakeVault { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Token, &token); + let default_tiers = default_multiplier_tiers(&env); + env.storage() + .instance() + .set(&DataKey::MultiplierTiers, &default_tiers); + StakeVaultInitialized { admin, token }.publish(&env); } + pub fn set_multiplier_tiers(env: Env, admin: Address, tiers: Vec) { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if admin != stored_admin { + panic!("Unauthorized"); + } + + validate_tiers(&tiers); + + env.storage() + .instance() + .set(&DataKey::MultiplierTiers, &tiers); + + MultiplierTiersUpdated { admin, tiers }.publish(&env); + } + + pub fn get_multiplier_tiers(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::MultiplierTiers) + .unwrap_or_else(|| default_multiplier_tiers(&env)) + } + pub fn stake(env: Env, user: Address, amount: i128) { user.require_auth(); @@ -144,13 +202,19 @@ impl StakeVault { lock_timestamp: 0, }); - if stake_info.amount >= 500 { - 200 - } else if stake_info.amount >= 100 { - 120 - } else { - 100 + let tiers: Vec = env + .storage() + .instance() + .get(&DataKey::MultiplierTiers) + .unwrap_or_else(|| default_multiplier_tiers(&env)); + + for tier in tiers.iter() { + if stake_info.amount >= tier.min_stake { + return tier.multiplier; + } } + + 100 } pub fn upgrade_contract(env: Env, admin: Address, new_wasm_hash: BytesN<32>) { diff --git a/contracts/stake-vault/src/test.rs b/contracts/stake-vault/src/test.rs index 656fdc8..4029825 100644 --- a/contracts/stake-vault/src/test.rs +++ b/contracts/stake-vault/src/test.rs @@ -2,11 +2,11 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, - token, Address, Env, + token, Address, Env, Vec, }; use crate::{ - types::{DataKey, StakeInfo}, + types::{DataKey, MultiplierTier, StakeInfo}, StakeVault, StakeVaultClient, }; @@ -205,3 +205,150 @@ fn test_get_multiplier() { }); assert_eq!(client.get_multiplier(&user), 200); } + +#[test] +fn test_set_multiplier_tiers_success() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let user = Address::generate(&env); + + client.initialize(&admin, &token_id.address()); + + let new_tiers = Vec::from_array( + &env, + [ + MultiplierTier { + min_stake: 1000, + multiplier: 300, + }, + MultiplierTier { + min_stake: 200, + multiplier: 150, + }, + ], + ); + + client.set_multiplier_tiers(&admin, &new_tiers); + assert_eq!(client.get_multiplier_tiers(), new_tiers); + + env.as_contract(&client.address, || { + env.storage().persistent().set( + &DataKey::UserStake(user.clone()), + &StakeInfo { + amount: 150, + lock_timestamp: 0, + }, + ); + }); + assert_eq!(client.get_multiplier(&user), 100); + + env.as_contract(&client.address, || { + env.storage().persistent().set( + &DataKey::UserStake(user.clone()), + &StakeInfo { + amount: 200, + lock_timestamp: 0, + }, + ); + }); + assert_eq!(client.get_multiplier(&user), 150); + + env.as_contract(&client.address, || { + env.storage().persistent().set( + &DataKey::UserStake(user.clone()), + &StakeInfo { + amount: 1000, + lock_timestamp: 0, + }, + ); + }); + assert_eq!(client.get_multiplier(&user), 300); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_set_multiplier_tiers_unauthorized() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + + client.initialize(&admin, &token_id.address()); + + let new_tiers = Vec::from_array( + &env, + [MultiplierTier { + min_stake: 500, + multiplier: 200, + }], + ); + + client.set_multiplier_tiers(&non_admin, &new_tiers); +} + +#[test] +#[should_panic(expected = "Tier min_stake must be strictly descending")] +fn test_set_multiplier_tiers_invalid_order() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + + client.initialize(&admin, &token_id.address()); + + let invalid_tiers = Vec::from_array( + &env, + [ + MultiplierTier { + min_stake: 100, + multiplier: 120, + }, + MultiplierTier { + min_stake: 500, + multiplier: 200, + }, + ], + ); + + client.set_multiplier_tiers(&admin, &invalid_tiers); +} + +#[test] +#[should_panic(expected = "Tier min_stake must be positive")] +fn test_set_multiplier_tiers_invalid_min_stake() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + + client.initialize(&admin, &token_id.address()); + + let invalid_tiers = Vec::from_array( + &env, + [MultiplierTier { + min_stake: 0, + multiplier: 150, + }], + ); + + client.set_multiplier_tiers(&admin, &invalid_tiers); +} + +#[test] +#[should_panic(expected = "Tier multiplier must be non-zero")] +fn test_set_multiplier_tiers_invalid_multiplier() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + + client.initialize(&admin, &token_id.address()); + + let invalid_tiers = Vec::from_array( + &env, + [MultiplierTier { + min_stake: 500, + multiplier: 0, + }], + ); + + client.set_multiplier_tiers(&admin, &invalid_tiers); +} diff --git a/contracts/stake-vault/src/types.rs b/contracts/stake-vault/src/types.rs index 615c54c..425c137 100644 --- a/contracts/stake-vault/src/types.rs +++ b/contracts/stake-vault/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address}; +use soroban_sdk::{contracttype, Address, Env, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -7,10 +7,34 @@ pub struct StakeInfo { pub lock_timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiplierTier { + pub min_stake: i128, + pub multiplier: u32, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, Token, UserStake(Address), + MultiplierTiers, +} + +pub fn default_multiplier_tiers(env: &Env) -> Vec { + Vec::from_array( + env, + [ + MultiplierTier { + min_stake: 500, + multiplier: 200, + }, + MultiplierTier { + min_stake: 100, + multiplier: 120, + }, + ], + ) }