Skip to content

Commit

Permalink
Add CoinGecko support
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexander Movchan committed Aug 28, 2020
1 parent 37468a2 commit 97ddd97
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 69 deletions.
22 changes: 20 additions & 2 deletions core/models/src/config_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ impl ProverOptions {
}
}

#[derive(Clone, Debug)]
pub enum TokenPriceSource {
CoinMarketCap { base_url: Url },
CoinGecko,
}

impl TokenPriceSource {
fn from_env() -> Self {
match get_env("TOKEN_PRICE_SOURCE").to_lowercase().as_str() {
"coinmarketcap" => Self::CoinMarketCap {
base_url: parse_env("COINMARKETCAP_BASE_URL"),
},
"coingecko" => Self::CoinGecko,
source => panic!("Unknown token price source: {}", source),
}
}
}

#[derive(Debug, Clone)]
pub struct ConfigurationOptions {
pub rest_api_server_address: SocketAddr,
Expand All @@ -126,13 +144,13 @@ pub struct ConfigurationOptions {
pub available_block_chunk_sizes: Vec<usize>,
pub eth_watch_poll_interval: Duration,
pub eth_network: String,
pub ticker_url: Url,
pub idle_provers: u32,
/// Max number of miniblocks (produced every period of `TX_MINIBATCH_CREATE_TIME`) if one block.
pub max_miniblock_iterations: usize,
/// Max number of miniblocks for block with withdraw operations (defaults to `max_minblock_iterations`).
pub max_miniblock_iterations_withdraw_block: usize,
pub prometheus_export_port: u16,
pub token_price_source: TokenPriceSource,
}

impl ConfigurationOptions {
Expand Down Expand Up @@ -174,11 +192,11 @@ impl ConfigurationOptions {
"ETH_WATCH_POLL_INTERVAL",
)),
eth_network: parse_env("ETH_NETWORK"),
ticker_url: parse_env("TICKER_URL"),
idle_provers: parse_env("IDLE_PROVERS"),
max_miniblock_iterations: parse_env("MINIBLOCKS_ITERATIONS"),
max_miniblock_iterations_withdraw_block,
prometheus_export_port: parse_env("PROMETHEUS_EXPORT_PORT"),
token_price_source: TokenPriceSource::from_env(),
}
}
}
Expand Down
32 changes: 26 additions & 6 deletions core/server/src/fee_ticker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ use num::{
traits::{Inv, Pow},
BigUint,
};
use reqwest::Url;
use tokio::{runtime::Runtime, task::JoinHandle};
// Workspace deps
use models::{
Expand All @@ -31,6 +30,8 @@ use models::{
};
use storage::ConnectionPool;
// Local deps
use crate::fee_ticker::ticker_api::coingecko::CoinGeckoAPI;
use crate::fee_ticker::ticker_api::coinmarkercap::CoinMarketCapAPI;
use crate::{
eth_sender::ETHSenderRequest,
fee_ticker::{
Expand All @@ -39,6 +40,7 @@ use crate::{
},
state_keeper::StateKeeperRequest,
};
use models::config_options::TokenPriceSource;

mod ticker_api;
mod ticker_info;
Expand Down Expand Up @@ -133,7 +135,7 @@ struct FeeTicker<API, INFO> {

#[must_use]
pub fn run_ticker_task(
api_base_url: Url,
token_price_source: TokenPriceSource,
db_pool: ConnectionPool,
eth_sender_request_sender: mpsc::Sender<ETHSenderRequest>,
state_keeper_request_sender: mpsc::Sender<StateKeeperRequest>,
Expand All @@ -155,11 +157,29 @@ pub fn run_ticker_task(
tokens_risk_factors: HashMap::new(),
};

let ticker_api = TickerApi::new(api_base_url, db_pool, eth_sender_request_sender);
let ticker_info = TickerInfo::new(state_keeper_request_sender);
let fee_ticker = FeeTicker::new(ticker_api, ticker_info, tricker_requests, ticker_config);
match token_price_source {
TokenPriceSource::CoinMarketCap { base_url } => {
let token_price_api = CoinMarketCapAPI::new(reqwest::Client::new(), base_url);

runtime.spawn(fee_ticker.run())
let ticker_api = TickerApi::new(db_pool, eth_sender_request_sender, token_price_api);
let ticker_info = TickerInfo::new(state_keeper_request_sender);
let fee_ticker =
FeeTicker::new(ticker_api, ticker_info, tricker_requests, ticker_config);

runtime.spawn(fee_ticker.run())
}
TokenPriceSource::CoinGecko => {
let token_price_api =
CoinGeckoAPI::new(reqwest::Client::new()).expect("failed to init CoinGecko client");

let ticker_api = TickerApi::new(db_pool, eth_sender_request_sender, token_price_api);
let ticker_info = TickerInfo::new(state_keeper_request_sender);
let fee_ticker =
FeeTicker::new(ticker_api, ticker_info, tricker_requests, ticker_config);

runtime.spawn(fee_ticker.run())
}
}
}

impl<API: FeeTickerAPI, INFO: FeeTickerInfo> FeeTicker<API, INFO> {
Expand Down
136 changes: 136 additions & 0 deletions core/server/src/fee_ticker/ticker_api/coingecko.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use crate::fee_ticker::ticker_api::TokenPriceAPI;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
use failure::Error;
use models::node::TokenPrice;
use models::primitives::UnsignedRatioSerializeAsDecimal;
use num::rational::Ratio;
use num::BigUint;
use reqwest::Url;
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;

/// The limit of time we are willing to wait for response.
const REQUEST_TIMEOUT: Duration = Duration::from_millis(5000);

const COINGECKO_API_BASE_URL: &str = "https://api.coingecko.com/api/v3/";

pub struct CoinGeckoAPI {
client: reqwest::Client,
token_ids: HashMap<String, String>,
}

impl CoinGeckoAPI {
pub fn new(client: reqwest::Client) -> Result<Self, Error> {
let token_list_url = Url::from_str(COINGECKO_API_BASE_URL)
.unwrap()
.join("coins/list")
.expect("failed to join URL path");

let token_list = reqwest::blocking::get(token_list_url)
.map_err(|err| failure::format_err!("CoinGecko API request failed: {}", err))?
.json::<CoinGeckoTokenList>()?;

let mut token_ids = HashMap::new();
for token in token_list.0 {
token_ids.insert(token.symbol, token.id);
}

Ok(Self { client, token_ids })
}
}

#[async_trait]
impl TokenPriceAPI for CoinGeckoAPI {
async fn get_price(&self, token_symbol: &str) -> Result<TokenPrice, Error> {
let token_id = self
.token_ids
.get(&token_symbol.to_lowercase())
.ok_or_else(|| {
failure::format_err!("Token '{}' is not listed on CoinGecko", token_symbol)
})?;

let market_chart_url = Url::from_str(COINGECKO_API_BASE_URL)
.unwrap()
.join(format!("coins/{}/market_chart", token_id).as_str())
.expect("failed to join URL path");

let request = self
.client
.get(market_chart_url)
.query(&[("vs_currency", "usd"), ("days", "1")]);

let api_request_future = tokio::time::timeout(REQUEST_TIMEOUT, request.send());

let market_chart = api_request_future
.await
.map_err(|_| failure::format_err!("CoinGecko API request timeout"))?
.map_err(|err| failure::format_err!("CoinGecko API request failed: {}", err))?
.json::<CoinGeckoMarketChart>()
.await?;

let last_updated_timestamp_ms = market_chart
.prices
.last()
.ok_or_else(|| failure::format_err!("CoinGecko returned empty price data"))?
.0;

let usd_price = market_chart
.prices
.into_iter()
.map(|token_price| token_price.1)
.min()
.ok_or_else(|| failure::format_err!("CoinGecko returned empty price data"))?;

let naive_last_updated = NaiveDateTime::from_timestamp(
last_updated_timestamp_ms / 1_000, // ms to s
(last_updated_timestamp_ms % 1_000) as u32 * 1_000_000, // ms to ns
);
let last_updated = DateTime::<Utc>::from_utc(naive_last_updated, Utc);

Ok(TokenPrice {
usd_price,
last_updated,
})
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct CoinGeckoTokenInfo {
id: String,
symbol: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct CoinGeckoTokenList(Vec<CoinGeckoTokenInfo>);

#[derive(Debug, Clone, Serialize, Deserialize)]
struct CoinGeckoTokenPrice(
pub i64, // timestamp (milliseconds)
#[serde(with = "UnsignedRatioSerializeAsDecimal")] pub Ratio<BigUint>, // price
);

#[derive(Debug, Clone, Serialize, Deserialize)]
struct CoinGeckoMarketChart {
prices: Vec<CoinGeckoTokenPrice>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_coingecko_api() {
let mut runtime = tokio::runtime::Builder::new()
.basic_scheduler()
.enable_all()
.build()
.expect("tokio runtime");
let client = reqwest::Client::new();
let api = CoinGeckoAPI::new(client).expect("coingecko init");
runtime
.block_on(api.get_price("ETH"))
.expect("Failed to get data from ticker");
}
}
80 changes: 48 additions & 32 deletions core/server/src/fee_ticker/ticker_api/coinmarkercap.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// Built-in deps
use std::{collections::HashMap, time::Duration};
// External deps
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use failure::format_err;
use num::{rational::Ratio, BigUint};
use reqwest::Url;
use serde::{Deserialize, Serialize};
// Workspace deps
use super::TokenPriceAPI;
use models::{
node::{TokenLike, TokenPrice},
primitives::UnsignedRatioSerializeAsDecimal,
Expand All @@ -15,38 +17,51 @@ use models::{
/// The limit of time we are willing to wait for response.
const REQUEST_TIMEOUT: Duration = Duration::from_millis(500);

pub(super) async fn fetch_coimarketcap_data(
client: &reqwest::Client,
base_url: &Url,
token_symbol: &str,
) -> Result<TokenPrice, failure::Error> {
let request_url = base_url
.join(&format!(
"cryptocurrency/quotes/latest?symbol={}",
token_symbol
))
.expect("failed to join url path");
pub struct CoinMarketCapAPI {
client: reqwest::Client,
base_url: Url,
}

impl CoinMarketCapAPI {
pub fn new(client: reqwest::Client, base_url: Url) -> Self {
Self { client, base_url }
}
}

let api_request_future = tokio::time::timeout(REQUEST_TIMEOUT, client.get(request_url).send());
#[async_trait]
impl TokenPriceAPI for CoinMarketCapAPI {
async fn get_price(&self, token_symbol: &str) -> Result<TokenPrice, failure::Error> {
dbg!("COINMARKETCAP", token_symbol);
let request_url = self
.base_url
.join(&format!(
"cryptocurrency/quotes/latest?symbol={}",
token_symbol
))
.expect("failed to join url path");

let mut api_response = api_request_future
.await
.map_err(|_| failure::format_err!("Coinmarketcap API request timeout"))?
.map_err(|err| failure::format_err!("Coinmarketcap API request failed: {}", err))?
.json::<CoinmarketCapResponse>()
.await?;
let mut token_info = api_response
.data
.remove(&TokenLike::Symbol(token_symbol.to_string()))
.ok_or_else(|| format_err!("Could not found token in response"))?;
let usd_quote = token_info
.quote
.remove(&TokenLike::Symbol("USD".to_string()))
.ok_or_else(|| format_err!("Could not found usd quote in response"))?;
Ok(TokenPrice {
usd_price: usd_quote.price,
last_updated: usd_quote.last_updated,
})
let api_request_future =
tokio::time::timeout(REQUEST_TIMEOUT, self.client.get(request_url).send());

let mut api_response = api_request_future
.await
.map_err(|_| failure::format_err!("Coinmarketcap API request timeout"))?
.map_err(|err| failure::format_err!("Coinmarketcap API request failed: {}", err))?
.json::<CoinmarketCapResponse>()
.await?;
let mut token_info = api_response
.data
.remove(&TokenLike::Symbol(token_symbol.to_string()))
.ok_or_else(|| format_err!("Could not found token in response"))?;
let usd_quote = token_info
.quote
.remove(&TokenLike::Symbol("USD".to_string()))
.ok_or_else(|| format_err!("Could not found usd quote in response"))?;
Ok(TokenPrice {
usd_price: usd_quote.price,
last_updated: usd_quote.last_updated,
})
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
Expand Down Expand Up @@ -80,10 +95,11 @@ mod test {
.enable_all()
.build()
.expect("tokio runtime");
let ticker_url = parse_env("TICKER_URL");
let ticker_url = parse_env("COINMARKETCAP_BASE_URL");
let client = reqwest::Client::new();
let api = CoinMarketCapAPI::new(client, ticker_url);
runtime
.block_on(fetch_coimarketcap_data(&client, &ticker_url, "ETH"))
.block_on(api.get_price("ETH"))
.expect("Failed to get data from ticker");
}

Expand Down
Loading

0 comments on commit 97ddd97

Please sign in to comment.