diff --git a/contracts/farm-manager/src/contract.rs b/contracts/farm-manager/src/contract.rs index f2ef24b..c124116 100644 --- a/contracts/farm-manager/src/contract.rs +++ b/contracts/farm-manager/src/contract.rs @@ -9,7 +9,9 @@ use amm::farm_manager::{ use mantra_utils::validate_contract; use crate::error::ContractError; -use crate::helpers::{validate_emergency_unlock_penalty, validate_unlocking_duration}; +use crate::helpers::{ + validate_emergency_unlock_penalty, validate_farm_expiration_time, validate_unlocking_duration, +}; use crate::state::{CONFIG, FARM_COUNTER}; use crate::{farm, manager, position, queries}; @@ -34,6 +36,9 @@ pub fn instantiate( // ensure the unlocking duration range is valid validate_unlocking_duration(msg.min_unlocking_duration, msg.max_unlocking_duration)?; + // ensure the farm expiration time is at least [MONTH_IN_SECONDS] + validate_farm_expiration_time(msg.farm_expiration_time)?; + // due to the circular dependency between the pool manager and the farm manager, // do not validate the pool manager address here, it has to be updated via the UpdateConfig msg // once the pool manager is instantiated @@ -46,6 +51,7 @@ pub fn instantiate( max_farm_epoch_buffer: msg.max_farm_epoch_buffer, min_unlocking_duration: msg.min_unlocking_duration, max_unlocking_duration: msg.max_unlocking_duration, + farm_expiration_time: msg.farm_expiration_time, emergency_unlock_penalty: validate_emergency_unlock_penalty(msg.emergency_unlock_penalty)?, }; @@ -91,7 +97,7 @@ pub fn execute( ) -> Result { match msg { ExecuteMsg::ManageFarm { action } => match action { - FarmAction::Fill { params } => manager::commands::fill_farm(deps, info, params), + FarmAction::Fill { params } => manager::commands::fill_farm(deps, env, info, params), FarmAction::Close { farm_identifier } => { manager::commands::close_farm(deps, info, farm_identifier) } @@ -134,6 +140,7 @@ pub fn execute( max_farm_epoch_buffer, min_unlocking_duration, max_unlocking_duration, + farm_expiration_time, emergency_unlock_penalty, } => { cw_utils::nonpayable(&info)?; @@ -148,6 +155,7 @@ pub fn execute( max_farm_epoch_buffer, min_unlocking_duration, max_unlocking_duration, + farm_expiration_time, emergency_unlock_penalty, ) } diff --git a/contracts/farm-manager/src/error.rs b/contracts/farm-manager/src/error.rs index 43964e4..4651a39 100644 --- a/contracts/farm-manager/src/error.rs +++ b/contracts/farm-manager/src/error.rs @@ -83,6 +83,9 @@ pub enum ContractError { #[error("The farm has already expired, can't be expanded")] FarmAlreadyExpired, + #[error("The expiration time for the farm is invalid, must be at least {min} seconds")] + FarmExpirationTimeInvalid { min: u64 }, + #[error("The farm doesn't have enough funds to pay out the reward")] FarmExhausted, diff --git a/contracts/farm-manager/src/farm/commands.rs b/contracts/farm-manager/src/farm/commands.rs index 3699bd9..bce1ad6 100644 --- a/contracts/farm-manager/src/farm/commands.rs +++ b/contracts/farm-manager/src/farm/commands.rs @@ -148,8 +148,8 @@ pub(crate) fn calculate_rewards( let mut modified_farms: HashMap = HashMap::new(); for farm in farms { - // skip expired farms - if farm.is_expired(current_epoch_id) || farm.start_epoch > current_epoch_id { + // skip farms that have not started + if farm.start_epoch > current_epoch_id { continue; } diff --git a/contracts/farm-manager/src/helpers.rs b/contracts/farm-manager/src/helpers.rs index e8e8d26..bc838c7 100644 --- a/contracts/farm-manager/src/helpers.rs +++ b/contracts/farm-manager/src/helpers.rs @@ -2,12 +2,14 @@ use std::cmp::Ordering; use std::collections::HashSet; use cosmwasm_std::{ - ensure, BankMsg, Coin, CosmosMsg, Decimal, MessageInfo, OverflowError, OverflowOperation, - Uint128, + ensure, BankMsg, Coin, CosmosMsg, Decimal, Deps, Env, MessageInfo, OverflowError, + OverflowOperation, Uint128, }; use amm::coin::{get_factory_token_creator, is_factory_token}; -use amm::farm_manager::{Config, FarmParams, Position, DEFAULT_FARM_DURATION}; +use amm::constants::MONTH_IN_SECONDS; +use amm::epoch_manager::{EpochResponse, QueryMsg}; +use amm::farm_manager::{Config, Farm, FarmParams, Position, DEFAULT_FARM_DURATION}; use crate::ContractError; @@ -197,6 +199,20 @@ pub(crate) fn validate_unlocking_duration( Ok(()) } +/// Validates the farm expiration time +pub(crate) fn validate_farm_expiration_time( + farm_expiration_time: u64, +) -> Result<(), ContractError> { + ensure!( + farm_expiration_time >= MONTH_IN_SECONDS, + ContractError::FarmExpirationTimeInvalid { + min: MONTH_IN_SECONDS + } + ); + + Ok(()) +} + /// Gets the unique LP asset denoms from a list of positions pub(crate) fn get_unique_lp_asset_denoms_from_positions(positions: Vec) -> Vec { positions @@ -206,3 +222,29 @@ pub(crate) fn get_unique_lp_asset_denoms_from_positions(positions: Vec .into_iter() .collect() } + +/// Checks if the farm is expired. A farm is considered to be expired if there's no more assets to claim +/// or if there has passed the config.farm_expiration_time since the farm ended. +pub(crate) fn is_farm_expired( + farm: &Farm, + deps: Deps, + env: &Env, + config: &Config, +) -> Result { + let epoch_response: EpochResponse = deps + .querier + // query preliminary_end_epoch + 1 because the farm is preliminary ending at that epoch, including it. + .query_wasm_smart( + config.epoch_manager_addr.to_string(), + &QueryMsg::Epoch { + id: farm.preliminary_end_epoch + 1u64, + }, + )?; + + let farm_ending_at = epoch_response.epoch.start_time; + + Ok( + farm.farm_asset.amount.saturating_sub(farm.claimed_amount) == Uint128::zero() + || farm_ending_at.plus_seconds(config.farm_expiration_time) < env.block.time, + ) +} diff --git a/contracts/farm-manager/src/manager/commands.rs b/contracts/farm-manager/src/manager/commands.rs index a6cde07..8a2bf74 100644 --- a/contracts/farm-manager/src/manager/commands.rs +++ b/contracts/farm-manager/src/manager/commands.rs @@ -1,42 +1,45 @@ use cosmwasm_std::{ - ensure, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, MessageInfo, Response, StdError, Storage, - Uint128, Uint64, + ensure, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, Env, MessageInfo, Response, StdError, + Storage, Uint128, Uint64, }; use amm::farm_manager::MIN_FARM_AMOUNT; use amm::farm_manager::{Curve, Farm, FarmParams}; use crate::helpers::{ - assert_farm_asset, process_farm_creation_fee, validate_emergency_unlock_penalty, - validate_farm_epochs, validate_lp_denom, validate_unlocking_duration, + assert_farm_asset, is_farm_expired, process_farm_creation_fee, + validate_emergency_unlock_penalty, validate_farm_epochs, validate_farm_expiration_time, + validate_lp_denom, validate_unlocking_duration, }; use crate::state::{get_farm_by_identifier, get_farms_by_lp_denom, CONFIG, FARMS, FARM_COUNTER}; use crate::ContractError; pub(crate) fn fill_farm( deps: DepsMut, + env: Env, info: MessageInfo, params: FarmParams, ) -> Result { // if a farm_identifier was passed in the params, check if a farm with such identifier - // exists and if the sender is allow to refill it, otherwise create a new farm - if let Some(farm_indentifier) = params.clone().farm_identifier { - let farm_result = get_farm_by_identifier(deps.storage, &farm_indentifier); + // exists and if the sender is allowed to refill it, otherwise create a new farm + if let Some(farm_identifier) = params.clone().farm_identifier { + let farm_result = get_farm_by_identifier(deps.storage, &farm_identifier); if let Ok(farm) = farm_result { // the farm exists, try to expand it - return expand_farm(deps, info, farm, params); + return expand_farm(deps, env, info, farm, params); } // the farm does not exist, try to create it } // if no identifier was passed in the params or if the farm does not exist, try to create the farm - create_farm(deps, info, params) + create_farm(deps, env, info, params) } /// Creates a farm with the given params fn create_farm( deps: DepsMut, + env: Env, info: MessageInfo, params: FarmParams, ) -> Result { @@ -60,7 +63,7 @@ fn create_farm( let (expired_farms, farms): (Vec<_>, Vec<_>) = farms .into_iter() - .partition(|farm| farm.is_expired(current_epoch.id)); + .partition(|farm| is_farm_expired(farm, deps.as_ref(), &env, &config).unwrap_or(false)); let mut messages: Vec = vec![]; @@ -217,6 +220,7 @@ fn close_farms( /// Expands a farm with the given params fn expand_farm( deps: DepsMut, + env: Env, info: MessageInfo, mut farm: Farm, params: FarmParams, @@ -225,14 +229,10 @@ fn expand_farm( ensure!(farm.owner == info.sender, ContractError::Unauthorized); let config = CONFIG.load(deps.storage)?; - let current_epoch = amm::epoch_manager::get_current_epoch( - deps.as_ref(), - config.epoch_manager_addr.into_string(), - )?; // check if the farm has already expired, can't be expanded ensure!( - !farm.is_expired(current_epoch.id), + !is_farm_expired(&farm, deps.as_ref(), &env, &config)?, ContractError::FarmAlreadyExpired ); @@ -292,6 +292,7 @@ pub(crate) fn update_config( max_farm_epoch_buffer: Option, min_unlocking_duration: Option, max_unlocking_duration: Option, + farm_expiration_time: Option, emergency_unlock_penalty: Option, ) -> Result { cw_ownable::assert_owner(deps.storage, &info.sender)?; @@ -337,6 +338,11 @@ pub(crate) fn update_config( config.min_unlocking_duration = min_unlocking_duration; } + if let Some(farm_expiration_time) = farm_expiration_time { + validate_farm_expiration_time(farm_expiration_time)?; + config.farm_expiration_time = farm_expiration_time; + } + if let Some(emergency_unlock_penalty) = emergency_unlock_penalty { config.emergency_unlock_penalty = validate_emergency_unlock_penalty(emergency_unlock_penalty)?; @@ -366,6 +372,10 @@ pub(crate) fn update_config( "max_unlocking_duration", config.max_unlocking_duration.to_string(), ), + ( + "farm_expiration_time", + config.farm_expiration_time.to_string(), + ), ( "emergency_unlock_penalty", config.emergency_unlock_penalty.to_string(), diff --git a/contracts/farm-manager/tests/common/suite.rs b/contracts/farm-manager/tests/common/suite.rs index 5e62c70..7f20bcc 100644 --- a/contracts/farm-manager/tests/common/suite.rs +++ b/contracts/farm-manager/tests/common/suite.rs @@ -1,3 +1,4 @@ +use amm::constants::MONTH_IN_SECONDS; use cosmwasm_std::testing::MockStorage; use cosmwasm_std::{coin, Addr, Coin, Decimal, Empty, StdResult, Timestamp, Uint128, Uint64}; use cw_multi_test::{ @@ -141,6 +142,7 @@ impl TestingSuite { 14, 86_400, 31_556_926, + MONTH_IN_SECONDS, Decimal::percent(10), //10% penalty ); @@ -208,6 +210,7 @@ impl TestingSuite { max_farm_epoch_buffer: u32, min_unlocking_duration: u64, max_unlocking_duration: u64, + farm_expiration_time: u64, emergency_unlock_penalty: Decimal, ) -> &mut Self { let msg = InstantiateMsg { @@ -220,6 +223,7 @@ impl TestingSuite { max_farm_epoch_buffer, min_unlocking_duration, max_unlocking_duration, + farm_expiration_time, emergency_unlock_penalty, }; @@ -253,6 +257,7 @@ impl TestingSuite { max_farm_epoch_buffer: u32, min_unlocking_duration: u64, max_unlocking_duration: u64, + farm_expiration_time: u64, emergency_unlock_penalty: Decimal, result: impl Fn(anyhow::Result), ) -> &mut Self { @@ -266,6 +271,7 @@ impl TestingSuite { max_farm_epoch_buffer, min_unlocking_duration, max_unlocking_duration, + farm_expiration_time, emergency_unlock_penalty, }; @@ -320,6 +326,7 @@ impl TestingSuite { max_farm_epoch_buffer: Option, min_unlocking_duration: Option, max_unlocking_duration: Option, + farm_expiration_time: Option, emergency_unlock_penalty: Option, funds: Vec, result: impl Fn(Result), @@ -333,6 +340,7 @@ impl TestingSuite { max_farm_epoch_buffer, min_unlocking_duration, max_unlocking_duration, + farm_expiration_time, emergency_unlock_penalty, }; diff --git a/contracts/farm-manager/tests/integration.rs b/contracts/farm-manager/tests/integration.rs index 1127732..f9a3796 100644 --- a/contracts/farm-manager/tests/integration.rs +++ b/contracts/farm-manager/tests/integration.rs @@ -1,6 +1,6 @@ extern crate core; -use amm::constants::LP_SYMBOL; +use amm::constants::{LP_SYMBOL, MONTH_IN_SECONDS}; use std::cell::RefCell; use cosmwasm_std::{coin, Addr, Coin, Decimal, Timestamp, Uint128}; @@ -35,6 +35,7 @@ fn instantiate_farm_manager() { 14, 86_400, 31_536_000, + MONTH_IN_SECONDS, Decimal::percent(10), |result| { let err = result.unwrap_err().downcast::().unwrap(); @@ -56,6 +57,7 @@ fn instantiate_farm_manager() { 14, 86_400, 86_399, + MONTH_IN_SECONDS, Decimal::percent(10), |result| { let err = result.unwrap_err().downcast::().unwrap(); @@ -77,6 +79,7 @@ fn instantiate_farm_manager() { 14, 86_400, 86_500, + MONTH_IN_SECONDS, Decimal::percent(101), |result| { let err = result.unwrap_err().downcast::().unwrap(); @@ -86,6 +89,28 @@ fn instantiate_farm_manager() { _ => panic!("Wrong error type, should return ContractError::InvalidEmergencyUnlockPenalty"), } }, + ).instantiate_err( + MOCK_CONTRACT_ADDR_1.to_string(), + MOCK_CONTRACT_ADDR_1.to_string(), + MOCK_CONTRACT_ADDR_1.to_string(), + Coin { + denom: "uom".to_string(), + amount: Uint128::new(1_000u128), + }, + 1, + 14, + 86_400, + 86_500, + MONTH_IN_SECONDS - 1, + Decimal::percent(101), + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::FarmExpirationTimeInvalid { .. } => {} + _ => panic!("Wrong error type, should return ContractError::FarmExpirationTimeInvalid"), + } + }, ).instantiate( MOCK_CONTRACT_ADDR_1.to_string(), MOCK_CONTRACT_ADDR_1.to_string(), @@ -98,6 +123,7 @@ fn instantiate_farm_manager() { 14, 86_400, 31_536_000, + MONTH_IN_SECONDS, Decimal::percent(10), //10% penalty ); } @@ -1032,6 +1058,7 @@ pub fn update_config() { max_farm_epoch_buffer: 14u32, min_unlocking_duration: 86_400u64, max_unlocking_duration: 31_556_926u64, + farm_expiration_time: MONTH_IN_SECONDS, emergency_unlock_penalty: Decimal::percent(10), }; @@ -1052,6 +1079,7 @@ pub fn update_config() { Some(15u32), Some(172_800u64), Some(864_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(50)), vec![coin(1_000, "uom")], |result| { @@ -1074,6 +1102,7 @@ pub fn update_config() { Some(15u32), Some(172_800u64), Some(864_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(50)), vec![], |result| { @@ -1096,6 +1125,7 @@ pub fn update_config() { Some(15u32), Some(172_800u64), Some(864_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(50)), vec![], |result| { @@ -1118,6 +1148,7 @@ pub fn update_config() { Some(15u32), Some(80_800u64), Some(80_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(50)), vec![], |result| { @@ -1140,6 +1171,7 @@ pub fn update_config() { Some(15u32), Some(300_000u64), Some(200_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(50)), vec![], |result| { @@ -1162,6 +1194,7 @@ pub fn update_config() { Some(15u32), Some(100_000u64), Some(200_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(105)), vec![], |result| { @@ -1184,6 +1217,7 @@ pub fn update_config() { Some(15u32), Some(100_000u64), Some(200_000u64), + Some(MONTH_IN_SECONDS * 2), Some(Decimal::percent(20)), vec![], |result| { @@ -1203,6 +1237,7 @@ pub fn update_config() { max_farm_epoch_buffer: 15u32, min_unlocking_duration: 100_000u64, max_unlocking_duration: 200_000u64, + farm_expiration_time: MONTH_IN_SECONDS * 2, emergency_unlock_penalty: Decimal::percent(20), }; @@ -1210,6 +1245,30 @@ pub fn update_config() { let config = result.unwrap(); assert_eq!(config, expected_config); }); + + suite.update_config( + &creator, + None, + None, + None, + None, + None, + None, + None, + None, + Some(MONTH_IN_SECONDS - 100), + None, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::FarmExpirationTimeInvalid { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::FarmExpirationTimeInvalid" + ), + } + }, + ); } #[test] @@ -1765,7 +1824,7 @@ pub fn test_manage_position() { farms_response.farms[0].farm_asset.amount, farms_response.farms[0].claimed_amount ); - assert!(farms_response.farms[0].is_expired(5)); + //assert!(farms_response.farms[0].is_expired(5)); }) .query_rewards(&creator, |result| { let rewards_response = result.unwrap(); @@ -2247,7 +2306,16 @@ fn claim_expired_farm_returns_nothing() { }) .query_balance("uusdy".to_string(), &other, |balance| { // the balance hasn't changed - assert_eq!(balance, Uint128::new(1_000_006_000u128)); + assert_eq!(balance, Uint128::new(1_000_008_000u128)); + }) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 1usize); + assert_eq!(farms_response.farms[0].claimed_amount, Uint128::new(8_000)); + + let farm_debt = + farms_response.farms[0].farm_asset.amount - farms_response.farms[0].claimed_amount; + assert_eq!(farm_debt, Uint128::zero()); }); } @@ -2831,7 +2899,7 @@ fn test_close_expired_farms() { denom: "uusdy".to_string(), amount: Uint128::new(8_000u128), }, - farm_identifier: None, + farm_identifier: Some("farm".to_string()), }, }, vec![coin(8_000, "uusdy"), coin(1_000, "uom")], @@ -2840,23 +2908,21 @@ fn test_close_expired_farms() { }, ); - // create a bunch of epochs to make the farm expire - for _ in 0..20 { + // create enough epochs to make the farm expire + for _ in 0..=37 { suite.add_one_epoch(); } - let mut current_id = 0; - // try opening another farm for the same lp denom, the expired farm should get closed suite .query_current_epoch(|result| { let epoch_response = result.unwrap(); - current_id = epoch_response.epoch.id; + assert_eq!(epoch_response.epoch.id, 48); }) .query_farms(None, None, None, |result| { let farms_response = result.unwrap(); assert_eq!(farms_response.farms.len(), 1); - assert!(farms_response.farms[0].is_expired(current_id)); + assert_eq!(farms_response.farms[0].identifier, "farm"); }) .manage_farm( &other, @@ -2894,9 +2960,9 @@ fn test_close_expired_farms() { claimed_amount: Uint128::zero(), emission_rate: Uint128::new(714), curve: Curve::Linear, - start_epoch: 31u64, - preliminary_end_epoch: 45u64, - last_epoch_claimed: 30u64, + start_epoch: 49u64, + preliminary_end_epoch: 63u64, + last_epoch_claimed: 48u64, } ); }); @@ -2917,58 +2983,87 @@ fn expand_expired_farm() { suite.instantiate_default(); - suite.manage_farm( - &other, - FarmAction::Fill { - params: FarmParams { - lp_denom: lp_denom.clone(), - start_epoch: None, - preliminary_end_epoch: None, - curve: None, - farm_asset: Coin { - denom: "uusdy".to_string(), - amount: Uint128::new(4_000u128), + suite + .manage_farm( + &other, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + farm_identifier: Some("farm".to_string()), }, - farm_identifier: Some("farm".to_string()), }, - }, - vec![coin(4_000, "uusdy"), coin(1_000, "uom")], - |result| { - result.unwrap(); - }, - ); + vec![coin(4_000, "uusdy"), coin(1_000, "uom")], + |result| { + result.unwrap(); + }, + ) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 1); + assert_eq!( + farms_response.farms[0], + Farm { + identifier: "farm".to_string(), + owner: other.clone(), + lp_denom: lp_denom.clone(), + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(285), + curve: Curve::Linear, + start_epoch: 1u64, + preliminary_end_epoch: 15u64, + last_epoch_claimed: 0u64, + } + ); + }); - // create a bunch of epochs to make the farm expire - for _ in 0..15 { + // create enough epochs to make the farm expire + // should expire at epoch 16 + config.farm_expiration_time, i.e. 16 + 30 = 46 + for _ in 0..=46 { suite.add_one_epoch(); } - suite.manage_farm( - &other, - FarmAction::Fill { - params: FarmParams { - lp_denom: lp_denom.clone(), - start_epoch: None, - preliminary_end_epoch: None, - curve: None, - farm_asset: Coin { - denom: "uusdy".to_string(), - amount: Uint128::new(8_000u128), + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 47); + }) + .manage_farm( + &other, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("farm".to_string()), }, - farm_identifier: Some("farm".to_string()), }, - }, - vec![coin(8_000u128, "uusdy")], - |result| { - let err = result.unwrap_err().downcast::().unwrap(); - match err { - ContractError::FarmAlreadyExpired { .. } => {} - _ => { - panic!("Wrong error type, should return ContractError::FarmAlreadyExpired") + vec![coin(8_000u128, "uusdy")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::FarmAlreadyExpired { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::FarmAlreadyExpired") + } } - } - }, - ); + }, + ); } #[test] @@ -4648,3 +4743,374 @@ fn test_positions_limits() { assert_eq!(response.positions.len(), MAX_ITEMS_LIMIT as usize); }); } + +#[test] +fn test_farm_expired() { + let lp_denom = format!("factory/{MOCK_CONTRACT_ADDR_1}/{LP_SYMBOL}").to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(2_000_000_000u128, "uom"), + coin(2_000_000_000u128, "uusdy"), + coin(2_000_000_000u128, "uosmo"), + coin(1_000_000_000u128, lp_denom.clone()), + coin(1_000_000_000u128, "invalid_lp"), + ]); + + let creator = suite.creator(); + + suite.instantiate_default(); + + for _ in 0..10 { + suite.add_one_epoch(); + } + + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 10); + }) + .manage_farm( + &creator, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(16), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("short_farm".to_string()), + }, + }, + vec![coin(8_000, "uusdy"), coin(1_000, "uom")], + |result| { + result.unwrap(); + }, + ) + .manage_farm( + &creator, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(100), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("long_farm".to_string()), + }, + }, + vec![coin(8_000, "uusdy"), coin(1_000, "uom")], + |result| { + result.unwrap(); + }, + ) + .manage_farm( + &creator, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(100), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("another_farm".to_string()), + }, + }, + vec![coin(8_000, "uusdy"), coin(1_000, "uom")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::TooManyFarms { .. } => {} + _ => panic!("Wrong error type, should return ContractError::TooManyFarms"), + } + }, + ); + + // create a few epochs, but not enough for the farm to expire. + // a farm expires after config.farm_expiration_time seconds from the epoch the farm ended + // in this case, from the start of epoch 17 + config.farm_expiration_time + for _ in 0..20 { + suite.add_one_epoch(); + } + + let mut current_epoch_id = 0; + + // try opening another farm for the same lp denom, the expired farm should get closed + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 30); + current_epoch_id = epoch_response.epoch.id; + }) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 2); + // not expired due to the claimed criteria + assert!(farms_response.farms[0].claimed_amount.is_zero()); + assert!(farms_response.farms[1].claimed_amount.is_zero()); + }); + + // creating a new farm of the same LP should fail as the previous ones are technically not expired yet + // otherwise the contract would close them automatically when someone tries to open a new farm of that + // same lp denom + suite.manage_farm( + &creator, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(100), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("another_farm".to_string()), + }, + }, + vec![coin(8_000, "uusdy"), coin(1_000, "uom")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::TooManyFarms { .. } => {} + _ => panic!("Wrong error type, should return ContractError::TooManyFarms"), + } + }, + ); + + // since the short epoch ended on epoch 16, and each epoch is 1 day, the farm should be expired + // on epoch 17.start_time + config.farm_expiration_time, which is set to a month. + // That is, epoch 48, let's move to that epoch + + for _ in 0..18 { + suite.add_one_epoch(); + } + + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 48); + current_epoch_id = epoch_response.epoch.id; + }) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 2); + + assert!(farms_response.farms[0].claimed_amount.is_zero()); + assert!(farms_response.farms[1].claimed_amount.is_zero()); + assert_eq!(farms_response.farms[0].identifier, "long_farm".to_string()); + assert_eq!(farms_response.farms[1].identifier, "short_farm".to_string()); + }); + + // the short farm should be expired by now, let's try creating a new farm + suite + .manage_farm( + &creator, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(50), + preliminary_end_epoch: Some(100), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("another_farm".to_string()), + }, + }, + vec![coin(8_000, "uusdy"), coin(1_000, "uom")], + |result| { + result.unwrap(); + }, + ) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 2); + + assert!(farms_response.farms[0].claimed_amount.is_zero()); + assert!(farms_response.farms[1].claimed_amount.is_zero()); + assert_eq!( + farms_response.farms[0].identifier, + "another_farm".to_string() + ); + assert_eq!(farms_response.farms[1].identifier, "long_farm".to_string()); + }); +} + +#[test] +fn user_can_claim_expired_epochs() { + let lp_denom = format!("factory/{MOCK_CONTRACT_ADDR_1}/{LP_SYMBOL}").to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(2_000_000_000u128, "uom".to_string()), + coin(2_000_000_000u128, "uusdy".to_string()), + coin(2_000_000_000u128, "uosmo".to_string()), + coin(2_000_000_000u128, lp_denom.clone()), + ]); + + let other = suite.senders[1].clone(); + let alice = suite.senders[2].clone(); + + suite.instantiate_default(); + + suite + .manage_farm( + &other, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(10), + preliminary_end_epoch: Some(20), + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + farm_identifier: Some("farm".to_string()), + }, + }, + vec![coin(4_000, "uusdy"), coin(1_000, "uom")], + |result| { + result.unwrap(); + }, + ) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 1); + assert_eq!( + farms_response.farms[0], + Farm { + identifier: "farm".to_string(), + owner: other.clone(), + lp_denom: lp_denom.clone(), + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(400), + curve: Curve::Linear, + start_epoch: 10u64, + preliminary_end_epoch: 20u64, + last_epoch_claimed: 9u64, + } + ); + }) + .manage_position( + &alice, + PositionAction::Fill { + identifier: Some("position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ); + + // create enough epochs to make the farm expire + // should expire at epoch 16 + config.farm_expiration_time, i.e. 16 + 30 = 46 + for _ in 0..100 { + suite.add_one_epoch(); + } + + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 100); + }) + // the farm expired, can't be refilled + .manage_farm( + &other, + FarmAction::Fill { + params: FarmParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(8_000u128), + }, + farm_identifier: Some("farm".to_string()), + }, + }, + vec![coin(8_000u128, "uusdy")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::FarmAlreadyExpired { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::FarmAlreadyExpired") + } + } + }, + ); + + // let's claim the rewards + + suite + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 1); + assert_eq!( + farms_response.farms[0], + Farm { + identifier: "farm".to_string(), + owner: other.clone(), + lp_denom: lp_denom.clone(), + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(400), + curve: Curve::Linear, + start_epoch: 10u64, + preliminary_end_epoch: 20u64, + last_epoch_claimed: 9u64, + } + ); + }) + .query_balance("uusdy".to_string(), &alice, |balance| { + assert_eq!(balance, Uint128::new(2_000_000_000)); + }) + .claim(&alice, vec![], |result| { + result.unwrap(); + }) + .query_balance("uusdy".to_string(), &alice, |balance| { + assert_eq!(balance, Uint128::new(2_000_004_000)); + }) + .query_farms(None, None, None, |result| { + let farms_response = result.unwrap(); + assert_eq!(farms_response.farms.len(), 1); + assert_eq!( + farms_response.farms[0], + Farm { + identifier: "farm".to_string(), + owner: other.clone(), + lp_denom: lp_denom.clone(), + farm_asset: Coin { + denom: "uusdy".to_string(), + amount: Uint128::new(4_000u128), + }, + claimed_amount: Uint128::new(4_000u128), + emission_rate: Uint128::new(400), + curve: Curve::Linear, + start_epoch: 10u64, + preliminary_end_epoch: 20u64, + last_epoch_claimed: 100u64, + } + ); + }); +} diff --git a/contracts/pool-manager/src/tests/integration_tests.rs b/contracts/pool-manager/src/tests/integration_tests.rs index cf545f7..a4b2acd 100644 --- a/contracts/pool-manager/src/tests/integration_tests.rs +++ b/contracts/pool-manager/src/tests/integration_tests.rs @@ -3708,11 +3708,11 @@ mod provide_liquidity { }, ) .query_balance(&creator.to_string(), &lp_denom, |result| { - // assert_approx_eq!( - // result.unwrap().amount, - // Uint128::from(30_000_000u128 + 1_500_000u128 + 1_500_000u128 - 1_000u128), - // "0.000001" - // ); + assert_approx_eq!( + result.unwrap().amount, + Uint128::from(30_000_000u128 + 1_500_000u128 + 1_500_000u128 - 1_000u128), + "0.000001" + ); }); // now alice provides liquidity diff --git a/contracts/pool-manager/src/tests/suite.rs b/contracts/pool-manager/src/tests/suite.rs index e06c482..634e98a 100644 --- a/contracts/pool-manager/src/tests/suite.rs +++ b/contracts/pool-manager/src/tests/suite.rs @@ -13,7 +13,7 @@ use cw_multi_test::{ WasmKeeper, }; -use amm::constants::LP_SYMBOL; +use amm::constants::{LP_SYMBOL, MONTH_IN_SECONDS}; use amm::epoch_manager::EpochConfig; use amm::farm_manager::PositionsResponse; use amm::fee::PoolFee; @@ -282,6 +282,7 @@ impl TestingSuite { max_farm_epoch_buffer: 014, min_unlocking_duration: 86_400, max_unlocking_duration: 31_536_000, + farm_expiration_time: MONTH_IN_SECONDS, emergency_unlock_penalty: Decimal::percent(10), }; @@ -512,6 +513,7 @@ impl TestingSuite { max_farm_epoch_buffer: None, min_unlocking_duration: None, max_unlocking_duration: None, + farm_expiration_time: None, emergency_unlock_penalty: None, }, &[], diff --git a/packages/amm/src/constants.rs b/packages/amm/src/constants.rs index cc7bd25..b6085c4 100644 --- a/packages/amm/src/constants.rs +++ b/packages/amm/src/constants.rs @@ -1,2 +1,3 @@ pub const LP_SYMBOL: &str = "LP"; pub const DAY_IN_SECONDS: u64 = 86_400u64; +pub const MONTH_IN_SECONDS: u64 = 2_629_746u64; diff --git a/packages/amm/src/farm_manager.rs b/packages/amm/src/farm_manager.rs index 81259e9..d082a48 100644 --- a/packages/amm/src/farm_manager.rs +++ b/packages/amm/src/farm_manager.rs @@ -25,6 +25,9 @@ pub struct InstantiateMsg { pub min_unlocking_duration: u64, /// The maximum amount of time that a user can lock their tokens for. In seconds. pub max_unlocking_duration: u64, + /// The amount of time after which a farm is considered to be expired after it ended. In seconds. + /// Once a farm is expired it cannot be expanded, and expired farms can be closed + pub farm_expiration_time: u64, /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. pub emergency_unlock_penalty: Decimal, } @@ -61,6 +64,9 @@ pub enum ExecuteMsg { min_unlocking_duration: Option, /// The maximum amount of time that a user can lock their tokens for. In seconds. max_unlocking_duration: Option, + /// The amount of time after which a farm is considered to be expired after it ended. In seconds. + /// Once a farm is expired it cannot be expanded, and expired farms can be closed + farm_expiration_time: Option, /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. emergency_unlock_penalty: Option, }, @@ -144,6 +150,9 @@ pub struct Config { pub min_unlocking_duration: u64, /// The maximum amount of time that a user can lock their tokens for. In seconds. pub max_unlocking_duration: u64, + /// The amount of time after which a farm is considered to be expired after it ended. In seconds. + /// Once a farm is expired it cannot be expanded, and expired farms can be closed + pub farm_expiration_time: u64, /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. pub emergency_unlock_penalty: Decimal, } @@ -240,15 +249,6 @@ pub struct Farm { pub last_epoch_claimed: EpochId, } -impl Farm { - /// Returns true if the farm is expired - pub fn is_expired(&self, epoch_id: EpochId) -> bool { - self.farm_asset.amount.saturating_sub(self.claimed_amount) < MIN_FARM_AMOUNT - || (epoch_id > self.start_epoch - && epoch_id >= self.last_epoch_claimed + DEFAULT_FARM_DURATION) - } -} - #[cw_serde] pub enum Curve { /// A linear curve that releases assets uniformly over time.