// 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::DB, 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 sum_sats = Decimal::from_u64(sum_sats).context("overflow")?; let sum_tokens = Decimal::from_u64(sum_tokens).context("overflow")?; let price = sum_sats.checked_div(sum_tokens).unwrap_or_default(); 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 .cauldron_r .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 .cauldron_r .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) }))) }