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
30pub 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
49pub 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
64pub 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
78pub 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
135pub 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(¶ms.emissions_controller, outpost_controller_msg)?,
161 fee: ibc_fee.clone(),
162 }
163 .into())
164}
165
166pub 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 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 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
198pub fn get_epoch_start(timestamp: u64) -> u64 {
200 let rem = timestamp % EPOCHS_START;
201 if rem % EPOCH_LENGTH == 0 {
202 timestamp
204 } else {
205 EPOCHS_START + rem / EPOCH_LENGTH * EPOCH_LENGTH
207 }
208}
209
210pub 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
225pub 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
258pub struct TuneResult {
260 pub candidates: Vec<(String, (String, Uint128))>,
262 pub new_emissions_state: EmissionsState,
264 pub next_pools_grouped: HashMap<String, Vec<(String, Uint128)>>,
266}
267
268pub 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 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), );
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 let total_selected_vp = candidates
302 .iter()
303 .take(total_pool_limit)
304 .fold(Uint128::zero(), |acc, (_, (_, vp))| acc + vp);
305 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 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 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
367pub fn jail_outpost(
370 storage: &mut dyn Storage,
371 prefix: &str,
372 env: Env,
373) -> Result<(), ContractError> {
374 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 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 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}