diff --git a/src/main.rs b/src/main.rs index e1068e1..ecf5ce2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -320,58 +320,6 @@ fn list_by_volume( Ok(Json(result)) } -#[get("/price//current")] -fn price_current(token: &str, conn: &State) -> Result, Custom> { - let db = conn - .get() - .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; - - let (tvl_sats, tvl_tokens, price) = rpc::current_price(&db, token) - .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; - - Ok(Json(json!({ - "price": price, - "tvl_sats": tvl_sats, - "tvl_tokens": tvl_tokens - }))) -} - -#[get("/price//history?&&")] -fn price_history( - token: &str, - start: Option, - end: Option, - stepsize: Option, - conn: &State, -) -> Result, Custom> { - let current_timestamp = time_now(); - - let history = rpc::historic_price( - &conn.get().unwrap(), - start.unwrap_or(current_timestamp - 30 * 24 * 3600 /* 30 days */), - end.unwrap_or(current_timestamp), - stepsize.unwrap_or(3600 /* 1 hour */), - token, - ) - .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; - - let history_json: Vec = history - .iter() - .map(|(time, avg, max, min)| { - json!({ - "time": time, - "avg": avg, - "max": max, - "min": min, - }) - }) - .collect(); - - Ok(Json(json!({ - "history": json!(history_json) - }))) -} - #[get("/pool/list_by_apy")] fn list_pools_by_apy(conn: &State) -> Result, Custom> { let pools: Vec = rpc::pools_by_apy(&conn.get().unwrap()) @@ -382,16 +330,6 @@ fn list_pools_by_apy(conn: &State) -> Result, Custom }))) } -#[get("/contract/count")] -fn contract_count(conn: &State) -> Result, Custom> { - let db = conn - .get() - .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; - let count = rpc::contract_count(&db) - .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; - Ok(Json(json!(count))) -} - #[get("/contract/volume?")] fn contract_volume( end: Option, @@ -553,10 +491,11 @@ fn launch() -> _ { rpc::tvl::valuelocked_token, rpc::tvl::valuelocked_all, list_by_volume, - price_history, - price_current, + rpc::price::price_history, + rpc::price::price_current, list_pools_by_apy, - contract_count, + rpc::contract::contract_count_token, + rpc::contract::contract_count_all, contract_volume ], ) diff --git a/src/rpc/contract.rs b/src/rpc/contract.rs new file mode 100644 index 0000000..783cf4a --- /dev/null +++ b/src/rpc/contract.rs @@ -0,0 +1,73 @@ +// Copyright (C) 2024 Riften Labs AS +// +// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html + +use anyhow::Result; +use rocket::{get, http::Status, response::status::Custom, serde::json::Json, State}; +use rusqlite::Connection; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::db::DBPool; + +#[derive(Serialize)] +pub struct ContractCount { + active: u64, + ended: u64, +} + +fn db_contract_count_all(db: &Connection) -> Result { + let active: u64 = db.query_row( + "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NULL", + [], + |row| row.get(0), + )?; + + let ended: u64 = db.query_row( + "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NOT NULL", + [], + |row| row.get(0), + )?; + + Ok(ContractCount { active, ended }) +} + +fn db_contract_count_by_token(db: &Connection, token_id: &str) -> Result { + let active: u64 = db.query_row( + "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NULL AND token_id = ?", + [token_id], + |row| row.get(0), + )?; + + let ended: u64 = db.query_row( + "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NOT NULL AND token_id = ?", + [token_id], + |row| row.get(0), + )?; + + Ok(ContractCount { active, ended }) +} + +#[get("/contract/count")] +pub fn contract_count_all(conn: &State) -> Result, Custom> { + let db = conn + .get() + .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; + let count = db_contract_count_all(&db) + .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; + Ok(Json(json!(count))) +} + +#[get("/contract/count/")] +pub fn contract_count_token( + token: &str, + conn: &State, +) -> Result, Custom> { + let db = conn + .get() + .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; + let count = db_contract_count_by_token(&db, token) + .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; + Ok(Json(json!(count))) +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index c31d060..6e4f5bb 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -3,15 +3,15 @@ // This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html -use anyhow::{bail, Context, Result}; -use rusqlite::{params, Connection}; -use rust_decimal::prelude::*; -use serde::Serialize; -use std::{ - collections::HashMap, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::collections::HashMap; +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; + +use crate::timeutil::time_now; + +pub mod contract; +pub mod price; pub mod tvl; #[allow(clippy::type_complexity)] @@ -106,175 +106,6 @@ pub fn list_tokens_by_volume( Ok(tokens) } -struct PriceInterval { - start: i64, - step: i64, - sats: i64, - tokens: i64, - min: f64, - max: f64, -} - -impl PriceInterval { - pub fn new(start: i64, step: i64) -> Self { - Self { - start, - step, - sats: 0, - tokens: 0, - min: f64::MAX, - max: f64::MIN, - } - } - - pub fn next(&self) -> Self { - let new_start = self.start + self.step; - Self::new(new_start, self.step) - } - - pub fn end(&self) -> i64 { - self.start + self.step - } - - pub fn avg_price(&self) -> Option { - if self.tokens == 0 { - return None; - } - Some(self.sats as f64 / self.tokens as f64) - } - - pub fn add_pool(&mut self, sats: i64, tokens: i64) { - if tokens != 0 { - let price = sats as f64 / tokens as f64; - if price > self.max { - self.max = price - } - if price < self.min { - self.min = price - } - } - self.sats += sats; - self.tokens += tokens; - } - - pub fn to_result(&self) -> Option<(i64, f64, f64, f64)> { - if self.tokens == 0 { - None - } else { - Some(( - self.start, - self.avg_price().expect("avg price not calculated"), - self.max, - self.min, - )) - } - } -} - -// Get the current price of a given token -pub fn current_price(db: &Connection, token_id: &str) -> Result<(u64, u64, f64)> { - let sql = "SELECT uf.sats, uf.token_amount - FROM utxo_funding uf - LEFT JOIN utxo_spending us ON uf.new_utxo_hash = us.spent_utxo_hash - WHERE us.spent_utxo_hash IS NULL AND uf.token_id = ?"; - - let mut statement = db.prepare(sql)?; - - let mut rows = statement.query(params![token_id])?; - - let mut sum_sats: u64 = 0; - let mut sum_tokens: u64 = 0; - - while let Some(row) = rows.next()? { - let sats: i64 = row.get(0)?; - let tokens: i64 = row.get(1)?; - - sum_sats += sats as u64; - sum_tokens += tokens as u64; - } - - let price = Decimal::from_u64(sum_sats).context("overflow")? - / Decimal::from_u64(sum_tokens).context("overflow")?; - - Ok((sum_sats, sum_tokens, price.to_f64().context("overflow")?)) -} - -#[allow(clippy::type_complexity)] -pub fn historic_price( - connection: &Connection, - timestamp_start: i64, // Start timestamp in posix - timestamp_end: i64, // End timestamp in posix - step_size: i64, // Interval in seconds (e.g., 600 for 10 minutes) - token_id: &str, -) -> Result> { - if timestamp_start > timestamp_end { - bail!("Start cannot be higher than end"); - } - - let total_intervals = (timestamp_end - timestamp_start) / step_size; - - const MAX_INTERVALS: i64 = 10000; - if total_intervals > MAX_INTERVALS { - bail!( - "Too many intervals ({} > {})", - total_intervals, - MAX_INTERVALS - ); - } - - // Prepare and execute the SQL query for the current interval - let sql = " - SELECT - COALESCE(tx.first_seen_timestamp, tx.mtp_timestamp) AS effective_timestamp, - utxo_funding.sats, - utxo_funding.token_amount - FROM - utxo_funding - LEFT JOIN - tx ON utxo_funding.txid = tx.txid - WHERE - utxo_funding.token_id = ? AND - effective_timestamp >= ? AND - effective_timestamp < ? - ORDER BY - effective_timestamp ASC -"; - let mut statement = connection.prepare(sql)?; - - let mut rows = statement.query(params![token_id, timestamp_start, timestamp_end])?; - - let mut result: Vec<(i64, f64, f64, f64)> = Vec::with_capacity(total_intervals as usize); - - let mut current_interval = PriceInterval::new(timestamp_start, step_size); - - while let Some(row) = rows.next()? { - let timestamp: i64 = row.get(0)?; - let sats: i64 = row.get(1)?; - let tokens: i64 = row.get(2)?; - - if timestamp >= current_interval.end() { - if let Some(r) = current_interval.to_result() { - result.push(r); - } - loop { - current_interval = current_interval.next(); - if timestamp < current_interval.end() { - break; - } - } - } - - current_interval.add_pool(sats, tokens); - } - - // final trade window - if let Some(r) = current_interval.to_result() { - result.push(r); - } - - Ok(result) -} - #[derive(serde::Serialize)] pub struct PoolYield { token_id: String, @@ -338,10 +169,7 @@ pub fn pools_by_apy(connection: &Connection) -> Result> { let mut statement = connection.prepare(sql)?; - let current_timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let current_timestamp = time_now(); let pool_rows = statement.query_map(params![], |row| { let original_sats: i64 = row.get(0)?; @@ -399,28 +227,6 @@ pub fn pools_by_apy(connection: &Connection) -> Result> { Ok(pools) } -#[derive(Serialize)] -pub struct ContractCount { - active: u64, - ended: u64, -} - -pub fn contract_count(db: &Connection) -> Result { - let active: u64 = db.query_row( - "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NULL", - [], - |row| row.get(0), - )?; - - let ended: u64 = db.query_row( - "SELECT COUNT(*) FROM pool WHERE withdrawn_in_utxo IS NOT NULL", - [], - |row| row.get(0), - )?; - - Ok(ContractCount { active, ended }) -} - fn all_time_volume(db: &Connection, end_timestamp: u64) -> Result> { let sql = " SELECT diff --git a/src/rpc/price.rs b/src/rpc/price.rs new file mode 100644 index 0000000..c6fdce8 --- /dev/null +++ b/src/rpc/price.rs @@ -0,0 +1,235 @@ +// Copyright (C) 2024 Riften Labs AS +// +// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. +// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html + +use anyhow::{bail, Context, Result}; +use rocket::{get, http::Status, response::status::Custom, serde::json::Json, State}; +use rusqlite::{params, Connection}; +use rust_decimal::prelude::*; +use serde_json::{json, Value}; + +use crate::{db::DBPool, timeutil::time_now}; + +struct PriceInterval { + start: i64, + step: i64, + sats: i64, + tokens: i64, + min: f64, + max: f64, +} + +impl PriceInterval { + pub fn new(start: i64, step: i64) -> Self { + Self { + start, + step, + sats: 0, + tokens: 0, + min: f64::MAX, + max: f64::MIN, + } + } + + pub fn next(&self) -> Self { + let new_start = self.start + self.step; + Self::new(new_start, self.step) + } + + pub fn end(&self) -> i64 { + self.start + self.step + } + + pub fn avg_price(&self) -> Option { + if self.tokens == 0 { + return None; + } + Some(self.sats as f64 / self.tokens as f64) + } + + pub fn add_pool(&mut self, sats: i64, tokens: i64) { + if tokens != 0 { + let price = sats as f64 / tokens as f64; + if price > self.max { + self.max = price + } + if price < self.min { + self.min = price + } + } + self.sats += sats; + self.tokens += tokens; + } + + pub fn to_result(&self) -> Option<(i64, f64, f64, f64)> { + if self.tokens == 0 { + None + } else { + Some(( + self.start, + self.avg_price().expect("avg price not calculated"), + self.max, + self.min, + )) + } + } +} + +// Get the current price of a given token +fn current_price(db: &Connection, token_id: &str) -> Result { + let sql = "SELECT uf.sats, uf.token_amount + FROM utxo_funding uf + LEFT JOIN utxo_spending us ON uf.new_utxo_hash = us.spent_utxo_hash + WHERE us.spent_utxo_hash IS NULL AND uf.token_id = ?"; + + let mut statement = db.prepare(sql)?; + + let mut rows = statement.query(params![token_id])?; + + let mut sum_sats: u64 = 0; + let mut sum_tokens: u64 = 0; + + while let Some(row) = rows.next()? { + let sats: i64 = row.get(0)?; + let tokens: i64 = row.get(1)?; + + sum_sats += sats as u64; + sum_tokens += tokens as u64; + } + + let price = Decimal::from_u64(sum_sats).context("overflow")? + / Decimal::from_u64(sum_tokens).context("overflow")?; + + price.to_f64().context("overflow") +} + +#[allow(clippy::type_complexity)] +fn historic_price( + connection: &Connection, + timestamp_start: i64, // Start timestamp in posix + timestamp_end: i64, // End timestamp in posix + step_size: i64, // Interval in seconds (e.g., 600 for 10 minutes) + token_id: &str, +) -> Result> { + if timestamp_start > timestamp_end { + bail!("Start cannot be higher than end"); + } + + let total_intervals = (timestamp_end - timestamp_start) / step_size; + + const MAX_INTERVALS: i64 = 10000; + if total_intervals > MAX_INTERVALS { + bail!( + "Too many intervals ({} > {})", + total_intervals, + MAX_INTERVALS + ); + } + + // Prepare and execute the SQL query for the current interval + let sql = " + SELECT + COALESCE(tx.first_seen_timestamp, tx.mtp_timestamp) AS effective_timestamp, + utxo_funding.sats, + utxo_funding.token_amount + FROM + utxo_funding + LEFT JOIN + tx ON utxo_funding.txid = tx.txid + WHERE + utxo_funding.token_id = ? AND + effective_timestamp >= ? AND + effective_timestamp < ? + ORDER BY + effective_timestamp ASC +"; + let mut statement = connection.prepare(sql)?; + + let mut rows = statement.query(params![token_id, timestamp_start, timestamp_end])?; + + let mut result: Vec<(i64, f64, f64, f64)> = Vec::with_capacity(total_intervals as usize); + + let mut current_interval = PriceInterval::new(timestamp_start, step_size); + + while let Some(row) = rows.next()? { + let timestamp: i64 = row.get(0)?; + let sats: i64 = row.get(1)?; + let tokens: i64 = row.get(2)?; + + if timestamp >= current_interval.end() { + if let Some(r) = current_interval.to_result() { + result.push(r); + } + loop { + current_interval = current_interval.next(); + if timestamp < current_interval.end() { + break; + } + } + } + + current_interval.add_pool(sats, tokens); + } + + // final trade window + if let Some(r) = current_interval.to_result() { + result.push(r); + } + + Ok(result) +} + +#[get("/price//current")] +pub fn price_current(token: &str, conn: &State) -> Result, Custom> { + let db = conn + .get() + .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; + + let price = current_price(&db, token) + .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; + + Ok(Json(json!({ + "price": price, + }))) +} + +#[get("/price//history?&&")] +pub fn price_history( + token: &str, + start: Option, + end: Option, + stepsize: Option, + conn: &State, +) -> Result, Custom> { + let current_timestamp = time_now(); + + let db = conn + .get() + .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; + + let history = historic_price( + &db, + start.unwrap_or(current_timestamp - 30 * 24 * 3600 /* 30 days */), + end.unwrap_or(current_timestamp), + stepsize.unwrap_or(3600 /* 1 hour */), + token, + ) + .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; + + let history_json: Vec = history + .iter() + .map(|(time, avg, max, min)| { + json!({ + "time": time, + "avg": avg, + "max": max, + "min": min, + }) + }) + .collect(); + + Ok(Json(json!({ + "history": json!(history_json) + }))) +}