diff --git a/contracts/stake/src/contract.rs b/contracts/stake/src/contract.rs index de24bf62c..f94fc163b 100644 --- a/contracts/stake/src/contract.rs +++ b/contracts/stake/src/contract.rs @@ -1,4 +1,4 @@ -use phoenix::ttl::{INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD}; +use phoenix::ttl::{DAY_IN_LEDGERS, INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD}; use soroban_sdk::{ contract, contractimpl, contractmeta, log, map, panic_with_error, vec, Address, BytesN, Env, Vec, @@ -22,6 +22,8 @@ use crate::{ token_contract, }; +const SIXTY_DAYS_IN_LEDGER_TIMESTAMP: u64 = DAY_IN_LEDGERS as u64 * 60u64; + // Metadata that is added on to the WASM custom section contractmeta!( key = "Description", @@ -56,6 +58,8 @@ pub trait StakingTrait { fn withdraw_rewards(env: Env, sender: Address); + fn consolidate_stakes(env: Env, sender: Address, stake_timestamps: Vec); + // QUERIES fn query_config(env: Env) -> ConfigResponse; @@ -285,6 +289,71 @@ impl StakingTrait for Staking { save_stakes(&env, &sender, &stakes); } + fn consolidate_stakes(env: Env, sender: Address, stake_timestamps: Vec) { + sender.require_auth(); + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + + let mut user_bonding_info = get_stakes(&env, &sender); + let present_timestamp = env.ledger().timestamp(); + let mut removed_stakes: Vec = Vec::new(&env); + + // remove the stakes the sender sends us from storage in reverse order + for stake_timestamp in stake_timestamps.iter() { + // verify that the stakes we are about to remove are > 60 days + assert!( + present_timestamp - stake_timestamp >= SIXTY_DAYS_IN_LEDGER_TIMESTAMP, + "Stake: Consolidate Stake: Cannot consolidate stakes -> less than 60 days for stake." + ); + + let (idx_to_remove, stake_to_remove) = user_bonding_info + .stakes + .iter() + .enumerate() + .find(|(_, el)| el.stake_timestamp == stake_timestamp) + .unwrap_or_else(|| { + log!( + &env, + "Stake: Consolidate Stakes: Cannot find stake for given timestamp: {}", + stake_timestamp + ); + panic_with_error!(&env, ContractError::StakeNotFound); + }); + + removed_stakes.push_back(stake_to_remove); + + user_bonding_info + .stakes + .remove(idx_to_remove as u32) + .unwrap_or_else(|| { + log!( + &env, + "Stake: Consolidate Stakes: Cannot remove stake with given timestamp: {}", + stake_timestamp + ); + panic_with_error!(&env, ContractError::StakeNotFound); + }); + } + + let mut new_stake = Stake { + stake: 0, + stake_timestamp: 0, // just a placeholder + }; + + // consolidate the stakes from sender into a single stake + for old_stake in removed_stakes.iter() { + new_stake.stake += old_stake.stake; + if new_stake.stake_timestamp < old_stake.stake_timestamp { + new_stake.stake_timestamp = old_stake.stake_timestamp; + } + } + + // update the storage again using the new consolidated stake + user_bonding_info.stakes.push_back(new_stake); + save_stakes(&env, &sender, &user_bonding_info); + } + // QUERIES fn query_config(env: Env) -> ConfigResponse { diff --git a/contracts/stake/src/tests/bond.rs b/contracts/stake/src/tests/bond.rs index 74dba88b2..8871853fb 100644 --- a/contracts/stake/src/tests/bond.rs +++ b/contracts/stake/src/tests/bond.rs @@ -13,7 +13,7 @@ use crate::{ contract::{Staking, StakingClient}, msg::{ConfigResponse, StakedResponse}, storage::{Config, Stake}, - tests::setup::{ONE_DAY, ONE_WEEK}, + tests::setup::{ONE_DAY, ONE_WEEK, SIXTY_DAYS}, }; const DEFAULT_COMPLEXITY: u32 = 7; @@ -459,3 +459,369 @@ fn initialize_staking_contract_should_panic_when_max_complexity_invalid() { &0u32, ); } + +#[test] +fn should_consolidate_all_stakes_after_sixty_days() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user, &100_000); + let mut user_stakes: Vec = Vec::new(&env); + + // stake 25 days + for _ in 0..5 { + env.ledger().with_mut(|li| { + li.timestamp += ONE_DAY * 5; + }); + + staking.bond(&user, &1_000); + user_stakes.push_back(Stake { + stake: 1_000, + stake_timestamp: env.ledger().timestamp(), + }) + } + + // move forward to day 60 + env.ledger().with_mut(|li| li.timestamp = SIXTY_DAYS); + + // 5 more stakes after day #60 + for _ in 0..5 { + env.ledger().with_mut(|li| { + li.timestamp += ONE_DAY * 5; + }); + + staking.bond(&user, &1_000); + user_stakes.push_back(Stake { + stake: 1_000, + stake_timestamp: env.ledger().timestamp(), + }) + } + + env.ledger().with_mut(|li| { + li.timestamp += SIXTY_DAYS * 5; + }); + + assert_eq!(staking.query_staked(&user).stakes, user_stakes); + + let mut last_three: Vec = Vec::new(&env); + + for stake in user_stakes.iter().rev().take(3) { + last_three.push_front(stake.stake_timestamp); + } + + staking.consolidate_stakes(&user, &last_three); + let updated_stakes = vec![ + &env, + Stake { + stake: 1_000, + stake_timestamp: 432000, + }, + Stake { + stake: 1_000, + stake_timestamp: 864000, + }, + Stake { + stake: 1_000, + stake_timestamp: 1296000, + }, + Stake { + stake: 1_000, + stake_timestamp: 1728000, + }, + Stake { + stake: 1_000, + stake_timestamp: 2160000, + }, + Stake { + stake: 1_000, + stake_timestamp: 5616000, + }, + Stake { + stake: 1_000, + stake_timestamp: 6048000, + }, + Stake { + stake: 3_000, + stake_timestamp: 7344000, + }, + ]; + assert_eq!(staking.query_staked(&user).stakes, updated_stakes); + + // consolidating one more time + staking.consolidate_stakes(&user, &vec![&env, 7344000u64, 6048000u64, 5616000u64]); + + assert_eq!( + staking.query_staked(&user).stakes, + vec![ + &env, + Stake { + stake: 1_000, + stake_timestamp: 432000 + }, + Stake { + stake: 1_000, + stake_timestamp: 864000 + }, + Stake { + stake: 1_000, + stake_timestamp: 1296000 + }, + Stake { + stake: 1_000, + stake_timestamp: 1728000 + }, + Stake { + stake: 1_000, + stake_timestamp: 2160000 + }, + Stake { + stake: 5_000, + stake_timestamp: 7344000 + } + ] + ); +} + +#[test] +#[should_panic( + expected = "Stake: Consolidate Stake: Cannot consolidate stakes -> less than 60 days for stake." +)] +fn should_fail_consolidation_when_all_stakes_are_less_than_60_days() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user, &50_000); + + // ensure all stakes are less than 60 days old + let mut user_stakes: Vec = Vec::new(&env); + for _ in 0..5 { + env.ledger().with_mut(|li| { + li.timestamp += ONE_DAY * 5; + }); + + staking.bond(&user, &1_000); + user_stakes.push_back(Stake { + stake: 1_000, + stake_timestamp: env.ledger().timestamp(), + }); + } + + assert_eq!(staking.query_staked(&user).stakes, user_stakes); + + let mut stake_timestamps: Vec = Vec::new(&env); + for stake in user_stakes.iter() { + stake_timestamps.push_front(stake.stake_timestamp); + } + + staking.consolidate_stakes(&user, &stake_timestamps); +} + +#[test] +#[should_panic( + expected = "Stake: Consolidate Stake: Cannot consolidate stakes -> less than 60 days for stake." +)] +fn should_fail_consolidation_with_mixture_of_valid_and_invalid_stakes() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user, &50_000); + + let mut user_stakes_timestamps: Vec = Vec::new(&env); + + // less than 60 days + for _ in 0..2 { + env.ledger().with_mut(|li| li.timestamp += ONE_WEEK); + staking.bond(&user, &1_000); + user_stakes_timestamps.push_back(env.ledger().timestamp()); + } + + // older than 60 days + env.ledger().with_mut(|li| li.timestamp += SIXTY_DAYS); + + for _ in 0..3 { + env.ledger().with_mut(|li| li.timestamp += ONE_WEEK); + staking.bond(&user, &1_000); + user_stakes_timestamps.push_back(env.ledger().timestamp()); + } + + staking.consolidate_stakes(&user, &user_stakes_timestamps); +} + +#[test] +#[should_panic(expected = "Stake: Consolidate Stakes: Cannot find stake for given timestamp")] +fn should_fail_consolidation_with_non_existing_timestamp() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user, &50_000); + + for _ in 0..3 { + env.ledger().with_mut(|li| li.timestamp += ONE_WEEK); + staking.bond(&user, &1_000); + } + + let invalid_timestamp = 0; // the non-existing stake + + staking.consolidate_stakes(&user, &vec![&env, invalid_timestamp]); +} + +#[test] +#[should_panic( + expected = "Stake: Consolidate Stake: Cannot consolidate stakes -> less than 60 days for stake." +)] +fn should_fail_consolidation_with_no_stakes() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + let timestamp = ONE_WEEK; + env.ledger().with_mut(|li| li.timestamp = timestamp); + + staking.consolidate_stakes(&user, &vec![&env, timestamp]); +} + +#[test] +fn should_consolidate_one_stake_at_sixty_days_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user, &50_000); + + env.ledger().with_mut(|li| li.timestamp = 0); + staking.bond(&user, &1_000); + + env.ledger().with_mut(|li| li.timestamp = SIXTY_DAYS); + + let stake_timestamps = vec![&env, 0]; + + staking.consolidate_stakes(&user, &stake_timestamps); + + assert_eq!( + staking.query_staked(&user).stakes, + vec![ + &env, + Stake { + stake: 1_000, + stake_timestamp: 0 + } + ] + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Auth, InvalidAction)")] +fn should_fail_consolidation_when_different_user_tries_to_consolidate() { + let env = Env::default(); + + let admin = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); // Unauthorized user + let lp_token = deploy_token_contract(&env, &admin); + let manager = Address::generate(&env); + let owner = Address::generate(&env); + + let staking = deploy_staking_contract( + &env, + admin.clone(), + &lp_token.address, + &manager, + &owner, + &DEFAULT_COMPLEXITY, + ); + + lp_token.mint(&user1, &50_000); + + env.ledger().with_mut(|li| li.timestamp = SIXTY_DAYS); + staking.bond(&user1, &1_000); + + staking.consolidate_stakes(&user2, &vec![&env, SIXTY_DAYS]); +} diff --git a/contracts/stake/src/tests/distribution.rs b/contracts/stake/src/tests/distribution.rs index 9a20517d1..5b79b2e84 100644 --- a/contracts/stake/src/tests/distribution.rs +++ b/contracts/stake/src/tests/distribution.rs @@ -8,8 +8,9 @@ use super::setup::{deploy_staking_contract, deploy_token_contract}; use pretty_assertions::assert_eq; use crate::{ + contract::{Staking, StakingClient}, msg::{WithdrawableReward, WithdrawableRewardsResponse}, - tests::setup::SIXTY_DAYS, + tests::setup::{ONE_WEEK, SIXTY_DAYS}, }; #[test] @@ -1194,3 +1195,90 @@ fn distribute_rewards_daily_multiple_times_same_stakes() { assert_eq!(reward_token.balance(&staking.address), 0); } + +// test should behave as `add_distribution_and_distribute_reward` but with consolidating the stakes +#[test] +fn consolidating_stakes_and_distribute_rewards() { + let env = Env::default(); + env.mock_all_auths(); + env.cost_estimate().budget().reset_unlimited(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let manager = Address::generate(&env); + let lp_token = deploy_token_contract(&env, &admin); + let reward_token = deploy_token_contract(&env, &admin); + + let staking = StakingClient::new(&env, &env.register(Staking, ())); + + staking.initialize( + &admin, + &lp_token.address, + &50, + &1_000, + &manager, + &admin, + &50u32, + ); + + staking.create_distribution_flow(&admin, &reward_token.address); + + let reward_amount: i128 = 100_000; + reward_token.mint(&admin, &reward_amount); + + lp_token.mint(&user, &1_000); + + // first 30 days + for _ in 0..3 { + staking.bond(&user, &100); + env.ledger().with_mut(|li| { + li.timestamp += ONE_WEEK; + }); + } + + // second 30 days + env.ledger().with_mut(|li| { + li.timestamp += ONE_WEEK * 2; + }); + + for _ in 0..4 { + staking.bond(&user, &100); + env.ledger().with_mut(|li| { + li.timestamp += ONE_WEEK; + }); + } + + // one month forward + env.ledger().with_mut(|li| { + li.timestamp += SIXTY_DAYS; + }); + + staking.consolidate_stakes( + &user, + &vec![&env, 3024000u64, 3628800u64, 4233600u64, 4838400u64], + ); + + for _ in 0..60 { + staking.distribute_rewards(&admin, &(reward_amount / 60i128), &reward_token.address); + env.ledger().with_mut(|li| { + li.timestamp += 3600 * 24; + }); + } + + assert_eq!( + staking.query_withdrawable_rewards(&user), + WithdrawableRewardsResponse { + rewards: vec![ + &env, + WithdrawableReward { + reward_address: reward_token.address.clone(), + reward_amount: 99_960_u128 + } + ] + } + ); + + staking.withdraw_rewards(&user); + + assert_eq!(reward_token.balance(&user), 99_960); +} diff --git a/contracts/stake/src/tests/setup.rs b/contracts/stake/src/tests/setup.rs index 9d393a5ac..240a075bb 100644 --- a/contracts/stake/src/tests/setup.rs +++ b/contracts/stake/src/tests/setup.rs @@ -27,8 +27,8 @@ fn install_stake_latest_wasm(env: &Env) -> BytesN<32> { const MIN_BOND: i128 = 1000; const MIN_REWARD: i128 = 1000; -pub const ONE_WEEK: u64 = 604800; pub const ONE_DAY: u64 = 86400; +pub const ONE_WEEK: u64 = 604800; pub const SIXTY_DAYS: u64 = 60 * ONE_DAY; pub fn deploy_staking_contract<'a>(