From c40d8e164deb6060e31bdeaadfcea3f1df82e232 Mon Sep 17 00:00:00 2001 From: alexatsejames-alt Date: Sun, 26 Apr 2026 00:06:36 -1200 Subject: [PATCH 1/2] feat(staking): implement tiered rewards based on lock duration --- src/staking/lib.rs | 121 ++++++++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/src/staking/lib.rs b/src/staking/lib.rs index 9a590ed..d135290 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; @@ -39,8 +47,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) { @@ -49,18 +56,32 @@ 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 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) { + 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]); @@ -72,29 +93,29 @@ 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(); - // Update rewards - info.accumulated_rewards += Self::calc_new_rewards(env.clone(), &info, current_time); + info.accumulated_rewards += Self::calc_new_rewards(base_rate, &info, current_time); info.amount += amount; info.last_updated = current_time; - info.lock_end = current_time - + env - .storage() - .instance() - .get::<_, u64>(&DataKey::LockPeriod) - .unwrap(); + info.lock_end = current_time + tier.lock_seconds; + // If adding to existing stake, keep the higher multiplier + if tier.rate_multiplier > info.rate_multiplier { + 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)); + env.storage().persistent().set(&DataKey::Stake(user.clone()), &info); + env.events().publish((symbol_short!("stake"), user), (amount, info.lock_end, info.rate_multiplier)); } pub fn withdraw(env: Env, user: Address) { @@ -111,34 +132,25 @@ impl StakingContract { assert!(info.amount > 0, "nothing to withdraw"); let current_time = env.ledger().timestamp(); - let rewards = - info.accumulated_rewards + Self::calc_new_rewards(env.clone(), &info, current_time); + 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 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; - // Penalties stay in contract as "unclaimed rewards" or similar - // Or just lost. } let total_to_send = amount_to_return + rewards; - // 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), - ); + env.events().publish((symbol_short!("withdraw"), user), (amount_to_return, rewards)); } pub fn claim_rewards(env: Env, user: Address) { @@ -153,22 +165,19 @@ impl StakingContract { user.require_auth(); let mut info = Self::get_stake_info(env.clone(), user.clone()); let current_time = env.ledger().timestamp(); - let rewards = - info.accumulated_rewards + Self::calc_new_rewards(env.clone(), &info, current_time); + 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); 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); + env.events().publish((symbol_short!("claim"), user), rewards); } pub fn get_stake_info(env: Env, user: Address) -> StakeInfo { @@ -180,15 +189,23 @@ 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; - (info.amount * rate * seconds) / REWARD_PRECISION + // rate_multiplier: 100 = 1x, 150 = 1.5x, 200 = 2x + (info.amount * base_rate * seconds * info.rate_multiplier) / (REWARD_PRECISION * 100) } } From 60ab1a5b0a9f2a9ef078c7dad2fee9c979374280 Mon Sep 17 00:00:00 2001 From: alexatsejames-alt Date: Tue, 28 Apr 2026 04:31:04 -1200 Subject: [PATCH 2/2] fix: lock_end and rate_multiplier stay in sync on re-stake --- src/staking/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/staking/lib.rs b/src/staking/lib.rs index d135290..067ea52 100644 --- a/src/staking/lib.rs +++ b/src/staking/lib.rs @@ -108,9 +108,10 @@ impl StakingContract { info.accumulated_rewards += Self::calc_new_rewards(base_rate, &info, current_time); info.amount += amount; info.last_updated = current_time; - info.lock_end = current_time + tier.lock_seconds; - // If adding to existing stake, keep the higher multiplier - if tier.rate_multiplier > info.rate_multiplier { + // 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; }