astroport_emissions_controller/
utils.rs

1use std::collections::{HashMap, HashSet};
2
3use astroport::asset::{determine_asset_info, Asset};
4use astroport::common::LP_SUBDENOM;
5use astroport::incentives::{IncentivesSchedule, InputSchedule};
6use cosmwasm_schema::cw_serde;
7use cosmwasm_schema::serde::Serialize;
8use cosmwasm_std::{
9    coin, Coin, CosmosMsg, Decimal, Deps, Env, Order, QuerierWrapper, StdError, StdResult, Storage,
10    Uint128,
11};
12use itertools::Itertools;
13use neutron_sdk::bindings::msg::{IbcFee, NeutronMsg};
14use neutron_sdk::bindings::query::NeutronQuery;
15use neutron_sdk::query::min_ibc_fee::query_min_ibc_fee;
16use neutron_sdk::sudo::msg::RequestPacketTimeoutHeight;
17
18use astroport_governance::emissions_controller::consts::{
19    EPOCHS_START, EPOCH_LENGTH, FEE_DENOM, IBC_TIMEOUT,
20};
21use astroport_governance::emissions_controller::hub::{
22    Config, EmissionsState, OutpostInfo, OutpostParams,
23};
24use astroport_governance::emissions_controller::outpost::OutpostMsg;
25use astroport_governance::emissions_controller::utils::check_lp_token;
26
27use crate::error::ContractError;
28use crate::state::{get_active_outposts, OUTPOSTS, POOLS_WHITELIST, TUNE_INFO, VOTED_POOLS};
29
30/// Determine outpost prefix from address or tokenfactory denom.
31pub fn determine_outpost_prefix(value: &str) -> Option<String> {
32    let mut maybe_addr = Some(value);
33
34    if value.starts_with("factory/") && value.ends_with(LP_SUBDENOM) {
35        maybe_addr = value.split('/').nth(1);
36    }
37
38    maybe_addr.and_then(|value| {
39        value.find('1').and_then(|delim_ind| {
40            if delim_ind > 0 && value.chars().all(char::is_alphanumeric) {
41                Some(value[..delim_ind].to_string())
42            } else {
43                None
44            }
45        })
46    })
47}
48
49/// Determine outpost prefix for the pool LP token and validate
50/// that this outpost exists.
51pub fn get_outpost_prefix(
52    pool: &str,
53    outpost_prefixes: &HashMap<String, OutpostInfo>,
54) -> Option<String> {
55    determine_outpost_prefix(pool).and_then(|maybe_prefix| {
56        if outpost_prefixes.contains_key(&maybe_prefix) {
57            Some(maybe_prefix)
58        } else {
59            None
60        }
61    })
62}
63
64/// Validate LP token denom or address matches outpost prefix.
65pub fn validate_outpost_prefix(value: &str, prefix: &str) -> Result<(), ContractError> {
66    determine_outpost_prefix(value)
67        .and_then(|maybe_prefix| {
68            if maybe_prefix == prefix {
69                Some(maybe_prefix)
70            } else {
71                None
72            }
73        })
74        .ok_or_else(|| ContractError::InvalidOutpostPrefix(value.to_string()))
75        .map(|_| ())
76}
77
78/// Helper function to get outpost prefix from an IBC channel.
79pub fn get_outpost_from_hub_channel(
80    store: &dyn Storage,
81    source_channel: String,
82    get_channel_closure: impl Fn(&OutpostParams) -> &String,
83) -> StdResult<String> {
84    get_active_outposts(store)?
85        .into_iter()
86        .find_map(|(outpost_prefix, outpost)| {
87            outpost.params.as_ref().and_then(|params| {
88                if get_channel_closure(params).eq(&source_channel) {
89                    Some(outpost_prefix.clone())
90                } else {
91                    None
92                }
93            })
94        })
95        .ok_or_else(|| {
96            StdError::generic_err(format!(
97                "Unknown outpost with {source_channel} ics20 channel"
98            ))
99        })
100}
101
102#[cw_serde]
103pub enum IbcHookMemo<T> {
104    Wasm { contract: String, msg: T },
105}
106
107impl<T: Serialize> IbcHookMemo<T> {
108    pub fn build(contract: &str, msg: T) -> StdResult<String> {
109        serde_json::to_string(&IbcHookMemo::Wasm {
110            contract: contract.to_string(),
111            msg,
112        })
113        .map_err(|err| StdError::generic_err(err.to_string()))
114    }
115}
116
117pub fn min_ntrn_ibc_fee(deps: Deps<NeutronQuery>) -> Result<IbcFee, ContractError> {
118    let fee = query_min_ibc_fee(deps)?.min_fee;
119
120    Ok(IbcFee {
121        recv_fee: fee.recv_fee,
122        ack_fee: fee
123            .ack_fee
124            .into_iter()
125            .filter(|a| a.denom == FEE_DENOM)
126            .collect(),
127        timeout_fee: fee
128            .timeout_fee
129            .into_iter()
130            .filter(|a| a.denom == FEE_DENOM)
131            .collect(),
132    })
133}
134
135/// Compose ics20 message with IBC hook memo for outpost emissions controller.
136pub fn build_emission_ibc_msg(
137    env: &Env,
138    params: &OutpostParams,
139    ibc_fee: &IbcFee,
140    astro_funds: Coin,
141    schedules: &[(String, InputSchedule)],
142) -> StdResult<CosmosMsg<NeutronMsg>> {
143    let outpost_controller_msg =
144        astroport_governance::emissions_controller::msg::ExecuteMsg::Custom(
145            OutpostMsg::SetEmissions {
146                schedules: schedules.to_vec(),
147            },
148        );
149    Ok(NeutronMsg::IbcTransfer {
150        source_port: "transfer".to_string(),
151        source_channel: params.ics20_channel.clone(),
152        token: astro_funds,
153        sender: env.contract.address.to_string(),
154        receiver: params.emissions_controller.clone(),
155        timeout_height: RequestPacketTimeoutHeight {
156            revision_number: None,
157            revision_height: None,
158        },
159        timeout_timestamp: env.block.time.plus_seconds(IBC_TIMEOUT).nanos(),
160        memo: IbcHookMemo::build(&params.emissions_controller, outpost_controller_msg)?,
161        fee: ibc_fee.clone(),
162    }
163    .into())
164}
165
166/// This function converts schedule pairs (lp_token, ASTRO amount)
167/// into the incentives contract executable message.
168/// It also calculates total ASTRO funds required for the emissions.
169pub fn raw_emissions_to_schedules(
170    env: &Env,
171    raw_schedules: &[(String, Uint128)],
172    schedule_denom: &str,
173    hub_denom: &str,
174) -> (Vec<(String, InputSchedule)>, Coin) {
175    let mut total_astro = Uint128::zero();
176    // Ensure emissions >=1 uASTRO per second.
177    // >= 1 uASTRO per second is the requirement in the incentives contract.
178    let schedules = raw_schedules
179        .iter()
180        .filter_map(|(pool, astro_amount)| {
181            let schedule = InputSchedule {
182                reward: Asset::native(schedule_denom, *astro_amount),
183                duration_periods: 1,
184            };
185            // Schedule validation imported from the incentives contract
186            IncentivesSchedule::from_input(env, &schedule).ok()?;
187
188            total_astro += astro_amount;
189            Some((pool.clone(), schedule))
190        })
191        .collect_vec();
192
193    let astro_funds = coin(total_astro.u128(), hub_denom);
194
195    (schedules, astro_funds)
196}
197
198/// Normalize current timestamp to the beginning of the current epoch (Monday).
199pub fn get_epoch_start(timestamp: u64) -> u64 {
200    let rem = timestamp % EPOCHS_START;
201    if rem % EPOCH_LENGTH == 0 {
202        // Hit at the beginning of the current epoch
203        timestamp
204    } else {
205        // Hit somewhere in the middle
206        EPOCHS_START + rem / EPOCH_LENGTH * EPOCH_LENGTH
207    }
208}
209
210/// Query the staking contract ASTRO balance and xASTRO total supply and derive xASTRO staking rate.
211/// Return (staking rate, total xASTRO supply).
212pub fn get_xastro_rate_and_share(
213    querier: QuerierWrapper,
214    config: &Config,
215) -> Result<(Decimal, Uint128), ContractError> {
216    let total_deposit = querier
217        .query_balance(&config.staking, &config.astro_denom)?
218        .amount;
219    let total_shares = querier.query_supply(&config.xastro_denom)?.amount;
220    let rate = Decimal::checked_from_ratio(total_deposit, total_shares)?;
221
222    Ok((rate, total_shares))
223}
224
225/// Calculate the number of ASTRO tokens collected by the staking contract from the previous epoch
226/// and derive emissions for the upcoming epoch.  
227///
228/// Calculate two-epochs EMA by the following formula:
229/// (V_n-1 * 2/3 + EMA_n-1 * 1/3),  
230/// where V_n is the collected ASTRO at epoch n, n is the current epoch (a starting one).
231///
232/// Dynamic emissions formula is:  
233/// next emissions = MAX(MIN(max_astro, V_n-1 * emissions_multiple), MIN(max_astro, two-epochs EMA))
234pub fn astro_emissions_curve(
235    deps: Deps,
236    emissions_state: EmissionsState,
237    config: &Config,
238) -> Result<EmissionsState, ContractError> {
239    let (actual_rate, shares) = get_xastro_rate_and_share(deps.querier, config)?;
240    let growth = actual_rate - emissions_state.xastro_rate;
241    let collected_astro = shares * growth;
242
243    let two_thirds = Decimal::from_ratio(2u8, 3u8);
244    let one_third = Decimal::from_ratio(1u8, 3u8);
245    let ema = collected_astro * two_thirds + emissions_state.ema * one_third;
246
247    let min_1 = (emissions_state.collected_astro * config.emissions_multiple).min(config.max_astro);
248    let min_2 = (ema * config.emissions_multiple).min(config.max_astro);
249
250    Ok(EmissionsState {
251        xastro_rate: actual_rate,
252        collected_astro,
253        ema,
254        emissions_amount: min_1.max(min_2),
255    })
256}
257
258/// Internal structure to pass the tune simulation result.
259pub struct TuneResult {
260    /// All candidates with their voting power and outpost prefix.
261    pub candidates: Vec<(String, (String, Uint128))>,
262    /// Dynammic emissions curve state
263    pub new_emissions_state: EmissionsState,
264    /// Next pools grouped by outpost prefix.
265    pub next_pools_grouped: HashMap<String, Vec<(String, Uint128)>>,
266}
267
268/// Simulate the next tune outcome based on the voting power distribution at given timestamp.
269/// In actual tuning context (function tune_pools) timestamp must match current epoch start.
270pub fn simulate_tune(
271    deps: Deps,
272    voted_pools: &HashSet<String>,
273    outposts: &HashMap<String, OutpostInfo>,
274    timestamp: u64,
275    config: &Config,
276) -> Result<TuneResult, ContractError> {
277    // Determine outpost prefix and filter out non-outpost pools.
278    let mut candidates = voted_pools
279        .iter()
280        .filter_map(|pool| get_outpost_prefix(pool, outposts).map(|prefix| (prefix, pool.clone())))
281        .map(|(prefix, pool)| {
282            let pool_vp = VOTED_POOLS
283                .may_load_at_height(deps.storage, &pool, timestamp)?
284                .map(|info| info.voting_power)
285                .unwrap_or_default();
286            Ok((prefix, (pool, pool_vp)))
287        })
288        .collect::<StdResult<Vec<_>>>()?;
289
290    candidates.sort_by(
291        |(_, (_, a)), (_, (_, b))| b.cmp(a), // Sort in descending order
292    );
293
294    let total_pool_limit = config.pools_per_outpost as usize * outposts.len();
295
296    let tune_info = TUNE_INFO.load(deps.storage)?;
297
298    let new_emissions_state = astro_emissions_curve(deps, tune_info.emissions_state, config)?;
299
300    // Total voting power of all selected pools
301    let total_selected_vp = candidates
302        .iter()
303        .take(total_pool_limit)
304        .fold(Uint128::zero(), |acc, (_, (_, vp))| acc + vp);
305    // Calculate each pool's ASTRO emissions
306    let mut next_pools = candidates
307        .iter()
308        .take(total_pool_limit)
309        .map(|(prefix, (pool, pool_vp))| {
310            let astro_for_pool = new_emissions_state
311                .emissions_amount
312                .multiply_ratio(*pool_vp, total_selected_vp);
313            (prefix.clone(), ((*pool).clone(), astro_for_pool))
314        })
315        .collect_vec();
316
317    // Add astro pools for each registered outpost
318    next_pools.extend(outposts.iter().filter_map(|(prefix, outpost)| {
319        outpost.astro_pool_config.as_ref().map(|astro_pool_config| {
320            (
321                prefix.clone(),
322                (
323                    astro_pool_config.astro_pool.clone(),
324                    astro_pool_config.constant_emissions,
325                ),
326            )
327        })
328    }));
329
330    let next_pools_grouped: HashMap<_, _> = next_pools
331        .into_iter()
332        .filter(|(_, (_, astro_for_pool))| !astro_for_pool.is_zero())
333        .into_group_map()
334        .into_iter()
335        .filter_map(|(prefix, pools)| {
336            if outposts.get(&prefix).unwrap().params.is_none() {
337                // Ensure on the Hub that all LP tokens are valid.
338                // Otherwise, keep ASTRO directed to invalid pools on the emissions controller.
339                let pools = pools
340                    .into_iter()
341                    .filter(|(pool, _)| {
342                        determine_asset_info(pool, deps.api)
343                            .and_then(|maybe_lp| {
344                                check_lp_token(deps.querier, &config.factory, &maybe_lp)
345                            })
346                            .is_ok()
347                    })
348                    .collect_vec();
349                if !pools.is_empty() {
350                    Some((prefix, pools))
351                } else {
352                    None
353                }
354            } else {
355                Some((prefix, pools))
356            }
357        })
358        .collect();
359
360    Ok(TuneResult {
361        candidates,
362        new_emissions_state,
363        next_pools_grouped,
364    })
365}
366
367/// Jails outpost as well as removes all whitelisted
368/// and being voted pools related to this outpost.
369pub fn jail_outpost(
370    storage: &mut dyn Storage,
371    prefix: &str,
372    env: Env,
373) -> Result<(), ContractError> {
374    // Remove all votable pools related to this outpost
375    let voted_pools = VOTED_POOLS
376        .keys(storage, None, None, Order::Ascending)
377        .collect::<StdResult<Vec<_>>>()?;
378    let prefix_some = Some(prefix.to_string());
379    voted_pools
380        .iter()
381        .filter(|pool| determine_outpost_prefix(pool) == prefix_some)
382        .try_for_each(|pool| VOTED_POOLS.remove(storage, pool, env.block.time.seconds()))?;
383
384    // And clear whitelist
385    POOLS_WHITELIST.update::<_, StdError>(storage, |mut whitelist| {
386        whitelist.retain(|pool| determine_outpost_prefix(pool) != prefix_some);
387        Ok(whitelist)
388    })?;
389
390    OUTPOSTS.update(storage, prefix, |outpost| {
391        if let Some(outpost) = outpost {
392            Ok(OutpostInfo {
393                jailed: true,
394                ..outpost
395            })
396        } else {
397            Err(ContractError::OutpostNotFound {
398                prefix: prefix.to_string(),
399            })
400        }
401    })?;
402
403    Ok(())
404}
405
406#[cfg(test)]
407mod unit_tests {
408    use super::*;
409
410    #[test]
411    fn test_determine_outpost_prefix() {
412        assert_eq!(
413            determine_outpost_prefix(&format!("factory/wasm1addr{LP_SUBDENOM}")).unwrap(),
414            "wasm"
415        );
416        assert_eq!(determine_outpost_prefix("wasm1addr").unwrap(), "wasm");
417        assert_eq!(determine_outpost_prefix("1addr"), None);
418        assert_eq!(
419            determine_outpost_prefix(&format!("factory/1addr{LP_SUBDENOM}")),
420            None
421        );
422        assert_eq!(determine_outpost_prefix("factory/wasm1addr/random"), None);
423        assert_eq!(
424            determine_outpost_prefix(&format!("factory{LP_SUBDENOM}")),
425            None
426        );
427    }
428
429    #[test]
430    fn test_epoch_start() {
431        assert_eq!(get_epoch_start(1716768000), 1716768000);
432        assert_eq!(get_epoch_start(1716768000 + 1), 1716768000);
433        assert_eq!(
434            get_epoch_start(1716768000 + EPOCH_LENGTH),
435            1716768000 + EPOCH_LENGTH
436        );
437        assert_eq!(
438            get_epoch_start(1716768000 + EPOCH_LENGTH + 1),
439            1716768000 + EPOCH_LENGTH
440        );
441
442        // Assert that epoch start date matches the official launch date (28 October 2024)
443        let official_launch_date = 1730073600;
444        assert_eq!(get_epoch_start(official_launch_date), official_launch_date);
445        assert_eq!(
446            get_epoch_start(official_launch_date + 1),
447            official_launch_date
448        );
449        assert_eq!(
450            get_epoch_start(official_launch_date + EPOCH_LENGTH),
451            official_launch_date + EPOCH_LENGTH
452        );
453        assert_eq!(
454            get_epoch_start(official_launch_date + EPOCH_LENGTH + 1),
455            official_launch_date + EPOCH_LENGTH
456        );
457    }
458}