diff --git a/src/contract.rs b/src/contract.rs index e47533f..7a8c3c7 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -4,8 +4,11 @@ use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult} use cw2::set_contract_version; use crate::error::ContractError; -use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, MigrateMsg}; -use crate::state::{Config, CONFIG, LAST_INDEX}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +use crate::state::{Config, CONFIG, LAST_INDEX, PRICE_HISTORY}; +use neutron_std::types::slinky::oracle::v1::{GetPriceResponse, OracleQuerier}; +use neutron_std::types::slinky::types::v1::CurrencyPair; // version info for migration info const CONTRACT_NAME: &str = "crates.io:neutron-oracle-history"; @@ -36,7 +39,8 @@ pub fn instantiate( // Initialize the ring buffer pointer for each pair to 0. for pair in msg.pairs.iter() { - LAST_INDEX.save(deps.storage, &pair, &0)?; + let key = pair_key(pair); + LAST_INDEX.save(deps.storage, &key, &0)?; } Ok(Response::new() @@ -51,12 +55,128 @@ pub fn instantiate( #[cfg_attr(not(feature = "library"), entry_point)] pub fn execute( - _deps: DepsMut, + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdatePrices {} => execute_update_prices(deps, env, info), + ExecuteMsg::UpdateConfig { + pairs, + update_period, + max_blocks_old, + history_size, + } => execute_update_config( + deps, + env, + info, + pairs, + update_period, + max_blocks_old, + history_size, + ), + } +} + +/// Triggered by the authorized Cron module. For each configured pair, this function: +/// 1. Queries the oracle price. +/// 2. Validates that the price is fresh (not too old) and not nil. +/// 3. Writes a new record to the ring buffer (only one record is read/written per pair). +pub fn execute_update_prices( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + // Only the authorized address (e.g. Cron module) can trigger this. + if info.sender != config.caller { + return Err(ContractError::Unauthorized {}); + } + + // Enforce the minimal update period. + if env.block.height < config.last_update + config.update_period { + return Err(ContractError::TooSoon { + expected: config.last_update + config.update_period, + }); + } + + let mut res = Response::new(); + + // Process each pair. + for pair in config.pairs.iter() { + let key = pair_key(pair); + // Query the oracle for the price. + let oracle_resp = query_oracle_price(&deps.as_ref(), pair)?; + + if let Some(price) = oracle_resp.price { + if !validate_price( + oracle_resp.nonce, + env.block.height, + price.block_height, + config.max_blocks_old, + ) { + res = res.add_attribute("skip", key.clone()); + continue; + } + + // Update the ring buffer for this pair. + let pointer = LAST_INDEX.may_load(deps.storage, &key)?.unwrap_or(0); + + PRICE_HISTORY.save(deps.storage, (key.as_str(), pointer), &price)?; + + // Update the pointer (wrap around using modulo arithmetic). + let new_pointer = (pointer + 1) % config.history_size; + LAST_INDEX.save(deps.storage, &key, &new_pointer)?; + + res = res.add_attribute("updated", key.clone()); + } else { + // If there's no oracle response, skip to the next item in the loop. + continue; + } + } + + // Update the last update block. + config.last_update = env.block.height; + CONFIG.save(deps.storage, &config)?; + Ok(res) +} + +/// Allows the authorized address to update configuration parameters. +pub fn execute_update_config( + deps: DepsMut, _env: Env, - _info: MessageInfo, - _msg: ExecuteMsg, + info: MessageInfo, + pairs: Option>, + update_period: Option, + max_blocks_old: Option, + history_size: Option, ) -> Result { - unimplemented!() + let mut config = CONFIG.load(deps.storage)?; + if info.sender != &config.owner { + return Err(ContractError::Unauthorized {}); + } + + if let Some(new_pairs) = pairs { + // Reset pointers for new pairs. + for pair in new_pairs.iter() { + let key = pair_key(pair); + LAST_INDEX.save(deps.storage, &key, &0)?; + } + config.pairs = new_pairs; + } + if let Some(up) = update_period { + config.update_period = up; + } + if let Some(mbo) = max_blocks_old { + config.max_blocks_old = mbo; + } + if let Some(hs) = history_size { + config.history_size = hs; + } + CONFIG.save(deps.storage, &config)?; + Ok(Response::new().add_attribute("action", "update_config")) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -71,12 +191,53 @@ pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { unimplemented!() } +/// ------------------------------------------------------------------------------------------------ +/// HELPERS +/// ------------------------------------------------------------------------------------------------ + +/// Query the oracle price for a given pair. +pub fn query_oracle_price( + deps: &Deps, + pair: &CurrencyPair, +) -> Result { + let querier = OracleQuerier::new(&deps.querier); + let price: GetPriceResponse = querier.get_price(Some(pair.clone()))?; + Ok(price) +} + +pub fn validate_price( + nonce: u64, + current_height: u64, + price_height: u64, + max_blocks_old: u64, +) -> bool { + // Check that the price received more than zero updates from validators. + // TODO(zavgorodnii): maybe introduce a parameter for the desired nonce? + if nonce == 0 { + return false; + } + + // Check that the price is not stale. + if current_height.saturating_sub(price_height) > max_blocks_old { + return false; + } + + true +} + +/// Returns a string key for a currency pair. +fn pair_key(pair: &CurrencyPair) -> String { + format!("{}-{}", pair.base, pair.quote) +} + +/// ------------------------------------------------------------------------------------------------ +/// TESTS +/// ------------------------------------------------------------------------------------------------ + #[cfg(test)] mod tests { use super::*; - use cosmwasm_std::{ - testing::{mock_dependencies, mock_env, message_info}, - }; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; #[test] fn test_instantiate_success() { @@ -89,14 +250,23 @@ mod tests { let instantiate_msg = InstantiateMsg { owner: deps.api.addr_make("owner_addr").into_string(), caller: deps.api.addr_make("caller_addr").into_string(), - pairs: vec!["pair1".to_string(), "pair2".to_string()], + pairs: vec![ + CurrencyPair { + base: "untrn".to_string(), + quote: "usd".to_string(), + }, + CurrencyPair { + base: "uatom".to_string(), + quote: "usd".to_string(), + }, + ], update_period: 10, max_blocks_old: 100, history_size: 5, }; // Call the instantiate function - let res = instantiate(deps.as_mut(), env.clone(), info, instantiate_msg.clone()) + instantiate(deps.as_mut(), env.clone(), info, instantiate_msg.clone()) .expect("contract initialization should succeed"); // Verify that the stored config matches the input data @@ -111,10 +281,11 @@ mod tests { // Verify that ring buffer pointers are initialized for each pair with 0 for pair in &instantiate_msg.pairs { + let key = pair_key(pair); let idx = LAST_INDEX - .load(&deps.storage, pair) + .load(&deps.storage, &key) .expect("pair index must be saved"); assert_eq!(idx, 0); } } -} \ No newline at end of file +} diff --git a/src/helpers.rs b/src/helpers.rs deleted file mode 100644 index 6ffc885..0000000 --- a/src/helpers.rs +++ /dev/null @@ -1,27 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg}; - -use crate::msg::ExecuteMsg; - -/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers -/// for working with this. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] -pub struct CwTemplateContract(pub Addr); - -impl CwTemplateContract { - pub fn addr(&self) -> Addr { - self.0.clone() - } - - pub fn call>(&self, msg: T) -> StdResult { - let msg = to_json_binary(&msg.into())?; - Ok(WasmMsg::Execute { - contract_addr: self.addr().into(), - msg, - funds: vec![], - } - .into()) - } -} diff --git a/src/lib.rs b/src/lib.rs index 233dbf5..dfedc9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,5 @@ pub mod contract; mod error; -pub mod helpers; pub mod msg; pub mod state; diff --git a/src/msg.rs b/src/msg.rs index 3b485df..3df2e74 100644 --- a/src/msg.rs +++ b/src/msg.rs @@ -1,6 +1,7 @@ -use neutron_std::types::slinky::oracle::v1::QuotePrice; +use crate::state::Config; use cosmwasm_schema::{cw_serde, QueryResponses}; -use crate::state::{Config}; +use neutron_std::types::slinky::oracle::v1::QuotePrice; +use neutron_std::types::slinky::types::v1::CurrencyPair; #[cw_serde] pub struct InstantiateMsg { @@ -9,7 +10,7 @@ pub struct InstantiateMsg { /// The authorized address (e.g. Cron module) that can trigger price updates. pub caller: String, /// The list of currency pairs to track. - pub pairs: Vec, + pub pairs: Vec, /// Minimal period (in blocks) between updates. pub update_period: u64, /// Maximum allowed block age for a price to be "fresh". @@ -25,7 +26,7 @@ pub enum ExecuteMsg { /// Update the configuration (only callable by the authorized address). UpdateConfig { /// Optionally update the list of currency pairs. - pairs: Option>, + pairs: Option>, /// Optionally update the update period (in blocks). update_period: Option, /// Optionally update the maximum allowed block age. @@ -45,7 +46,7 @@ pub enum QueryMsg { #[returns(HistoryResponse)] History { /// The list of currency pairs to query. - pairs: Vec, + pairs: Vec, }, } @@ -53,7 +54,7 @@ pub enum QueryMsg { #[cw_serde] pub struct HistoryResponse { /// A list of tuples containing the currency pair and its ordered list of price records. - pub histories: Vec<(String, Vec)>, + pub histories: Vec<(CurrencyPair, Vec)>, } /// The query response for the config query. diff --git a/src/state.rs b/src/state.rs index 616c03d..4b192d6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,9 @@ use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; +use neutron_std::types::slinky::oracle::v1::QuotePrice; +use neutron_std::types::slinky::types::v1::CurrencyPair; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use neutron_std::types::slinky::oracle::v1::QuotePrice; -use cw_storage_plus::{Item, Map}; /// Contract configuration. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] @@ -12,7 +13,7 @@ pub struct Config { /// The authorized address (e.g. Cron module) allowed to trigger updates. pub caller: Addr, /// The list of currency pairs for which we want to store prices. - pub pairs: Vec, + pub pairs: Vec, /// Minimal period (in blocks) between price updates. pub update_period: u64, /// Maximum number of blocks a price can be old to be considered fresh. @@ -32,4 +33,3 @@ pub const LAST_INDEX: Map<&str, u64> = Map::new("last_index"); /// Ring-buffer storage for price records for each pair. /// The key is a tuple: (pair identifier, index within the ring buffer). pub const PRICE_HISTORY: Map<(&str, u64), QuotePrice> = Map::new("price_history"); -