diff --git a/src/staking/lib.rs b/src/staking/lib.rs index 58ebc33..5e99de5 100644 --- a/src/staking/lib.rs +++ b/src/staking/lib.rs @@ -1,16 +1,23 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, vec, Address, Env, Vec}; #[contracttype] pub enum DataKey { Admin, Token, - RewardRate, // Rewards per second per token (scaled by 1e7) - LockPeriod, // Seconds - PenaltyBps, // Penalty percentage (10000 = 100%) + BaseRate, // Base rewards per second per token (scaled by 1e7) + Tiers, // Vec + PenaltyBps, Stake(Address), } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockTier { + pub lock_seconds: u64, + pub rate_multiplier: i128, // e.g. 100 = 1x, 150 = 1.5x, 200 = 2x (scaled by 100) +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct StakeInfo { @@ -18,6 +25,7 @@ pub struct StakeInfo { pub last_updated: u64, pub accumulated_rewards: i128, pub lock_end: u64, + pub rate_multiplier: i128, } const REWARD_PRECISION: i128 = 10_000_000; @@ -44,8 +52,7 @@ impl StakingContract { env: Env, admin: Address, token: Address, - reward_rate: i128, - lock_period: u64, + base_rate: i128, penalty_bps: i128, ) { if env.storage().instance().has(&DataKey::Admin) { @@ -54,28 +61,35 @@ impl StakingContract { admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Token, &token); - env.storage() - .instance() - .set(&DataKey::RewardRate, &reward_rate); - env.storage() - .instance() - .set(&DataKey::LockPeriod, &lock_period); - env.storage() - .instance() - .set(&DataKey::PenaltyBps, &penalty_bps); + env.storage().instance().set(&DataKey::BaseRate, &base_rate); + env.storage().instance().set(&DataKey::PenaltyBps, &penalty_bps); + + // Default tiers: 1 month (1x), 3 months (1.25x), 6 months (1.5x), 12 months (2x) + let default_tiers: Vec = vec![ + &env, + LockTier { lock_seconds: 30 * 24 * 3600, rate_multiplier: 100 }, + LockTier { lock_seconds: 90 * 24 * 3600, rate_multiplier: 125 }, + LockTier { lock_seconds: 180 * 24 * 3600, rate_multiplier: 150 }, + LockTier { lock_seconds: 365 * 24 * 3600, rate_multiplier: 200 }, + ]; + env.storage().instance().set(&DataKey::Tiers, &default_tiers); } - pub fn stake(env: Env, user: Address, amount: i128) { - if let Some(registry) = env - .storage() - .instance() - .get::<_, soroban_sdk::Address>(&soroban_sdk::symbol_short!("sec_reg")) - { - let is_paused: bool = env.invoke_contract( - ®istry, - &soroban_sdk::Symbol::new(&env, "is_paused"), - soroban_sdk::vec![&env], - ); + pub fn set_tiers(env: Env, tiers: Vec) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + assert!(tiers.len() > 0, "need at least one tier"); + env.storage().instance().set(&DataKey::Tiers, &tiers); + } + + pub fn get_tiers(env: Env) -> Vec { + env.storage().instance().get(&DataKey::Tiers).unwrap() + } + + pub fn stake(env: Env, user: Address, amount: i128, tier_index: u32) { + + if let Some(registry) = env.storage().instance().get::<_, soroban_sdk::Address>(&soroban_sdk::symbol_short!("sec_reg")) { + let is_paused: bool = env.invoke_contract(®istry, &soroban_sdk::Symbol::new(&env, "is_paused"), soroban_sdk::vec![&env]); if is_paused { panic!("contract is paused"); } @@ -84,13 +98,30 @@ impl StakingContract { user.require_auth(); assert!(amount > 0, "amount must be positive"); + let tiers: Vec = env.storage().instance().get(&DataKey::Tiers).unwrap(); + assert!((tier_index as u32) < tiers.len(), "invalid tier"); + let tier = tiers.get(tier_index).unwrap(); + let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let token_client = token::Client::new(&env, &token_addr); token_client.transfer(&user, &env.current_contract_address(), &amount); let mut info = Self::get_stake_info(env.clone(), user.clone()); let current_time = env.ledger().timestamp(); + let base_rate: i128 = env.storage().instance().get(&DataKey::BaseRate).unwrap(); + info.accumulated_rewards += Self::calc_new_rewards(base_rate, &info, current_time); + info.amount += amount; + info.last_updated = current_time; + // Always extend lock from now; take the furthest end date + let new_lock_end = current_time + tier.lock_seconds; + if new_lock_end > info.lock_end { + info.lock_end = new_lock_end; + info.rate_multiplier = tier.rate_multiplier; + } + + env.storage().persistent().set(&DataKey::Stake(user.clone()), &info); + env.events().publish((symbol_short!("stake"), user), (amount, info.lock_end, info.rate_multiplier)); // Update rewards info.accumulated_rewards = info.accumulated_rewards .checked_add(Self::calc_new_rewards(env.clone(), &info, current_time)) @@ -135,13 +166,16 @@ impl StakingContract { assert!(info.amount > 0, "nothing to withdraw"); let current_time = env.ledger().timestamp(); + let base_rate: i128 = env.storage().instance().get(&DataKey::BaseRate).unwrap(); + let rewards = info.accumulated_rewards + Self::calc_new_rewards(base_rate, &info, current_time); let rewards = info.accumulated_rewards.checked_add(Self::calc_new_rewards(env.clone(), &info, current_time)).expect("rewards overflow"); let mut amount_to_return = info.amount; - // Apply penalty if before lock_end if current_time < info.lock_end { let penalty_bps: i128 = env.storage().instance().get(&DataKey::PenaltyBps).unwrap(); + let penalty = (amount_to_return * penalty_bps) / 10000; + amount_to_return -= penalty; let penalty = amount_to_return.checked_mul(penalty_bps).expect("penalty overflow") / 10000; amount_to_return = amount_to_return.checked_sub(penalty).expect("penalty underflow"); // Penalties stay in contract as "unclaimed rewards" or similar @@ -150,15 +184,13 @@ impl StakingContract { let total_to_send = amount_to_return.checked_add(rewards).expect("total overflow"); - // Reset stake info - env.storage() - .persistent() - .remove(&DataKey::Stake(user.clone())); + env.storage().persistent().remove(&DataKey::Stake(user.clone())); let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let token_client = token::Client::new(&env, &token_addr); token_client.transfer(&env.current_contract_address(), &user, &total_to_send); + env.events().publish((symbol_short!("withdraw"), user), (amount_to_return, rewards)); // Topic: event name only; user + amounts in data. env.events().publish( symbol_short!("withdraw"), @@ -185,20 +217,21 @@ impl StakingContract { user.require_auth(); let mut info = Self::get_stake_info(env.clone(), user.clone()); let current_time = env.ledger().timestamp(); + let base_rate: i128 = env.storage().instance().get(&DataKey::BaseRate).unwrap(); + let rewards = info.accumulated_rewards + Self::calc_new_rewards(base_rate, &info, current_time); let rewards = info.accumulated_rewards.checked_add(Self::calc_new_rewards(env.clone(), &info, current_time)).expect("rewards overflow"); assert!(rewards > 0, "no rewards to claim"); info.accumulated_rewards = 0; info.last_updated = current_time; - env.storage() - .persistent() - .set(&DataKey::Stake(user.clone()), &info); + env.storage().persistent().set(&DataKey::Stake(user.clone()), &info); let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let token_client = token::Client::new(&env, &token_addr); token_client.transfer(&env.current_contract_address(), &user, &rewards); + env.events().publish((symbol_short!("claim"), user), rewards); // Topic: event name only; user + rewards in data. env.events() .publish(symbol_short!("claim"), (user, rewards)); @@ -213,15 +246,24 @@ impl StakingContract { last_updated: 0, accumulated_rewards: 0, lock_end: 0, + rate_multiplier: 100, }) } - fn calc_new_rewards(env: Env, info: &StakeInfo, current_time: u64) -> i128 { + pub fn pending_rewards(env: Env, user: Address) -> i128 { + let info = Self::get_stake_info(env.clone(), user); + let base_rate: i128 = env.storage().instance().get(&DataKey::BaseRate).unwrap(); + let current_time = env.ledger().timestamp(); + info.accumulated_rewards + Self::calc_new_rewards(base_rate, &info, current_time) + } + + fn calc_new_rewards(base_rate: i128, info: &StakeInfo, current_time: u64) -> i128 { if info.amount == 0 || info.last_updated == 0 || current_time <= info.last_updated { return 0; } - let rate: i128 = env.storage().instance().get(&DataKey::RewardRate).unwrap(); let seconds = (current_time - info.last_updated) as i128; + // rate_multiplier: 100 = 1x, 150 = 1.5x, 200 = 2x + (info.amount * base_rate * seconds * info.rate_multiplier) / (REWARD_PRECISION * 100) info.amount.checked_mul(rate).expect("reward overflow").checked_mul(seconds).expect("reward overflow") / REWARD_PRECISION } }