// 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 bitcoin_hashes::hex::{FromHex, ToHex}; use bitcoincash::TokenID; 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::{cauldron::pool::db_list_active_pools, 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 active = db_list_active_pools(Some(token_id), None, db, false)?; let mut sats: u128 = 0; let mut tokens: u128 = 0; for pool in active { sats += pool.sats as u128; tokens += pool.tokens as u128; } let sum_sats = Decimal::from_u128(sats).context("overflow")?; let sum_tokens = Decimal::from_u128(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) } fn price_at_or_before( connection: &Connection, timestamp: i64, token_id: &TokenID, ) -> Result<(i64, f64)> { let sql = " WITH max_timestamps AS ( SELECT phe.pool, MAX(COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp)) AS max_effective_timestamp FROM pool_history_entry phe JOIN utxo_funding uf ON phe.utxo = uf.new_utxo_hash WHERE COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) <= ? AND uf.token_id = ? GROUP BY phe.pool ) SELECT uf.token_amount, uf.sats, COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) AS effective_timestamp FROM pool_history_entry phe JOIN utxo_funding uf ON phe.utxo = uf.new_utxo_hash JOIN pool p ON p.creation_utxo = phe.pool JOIN max_timestamps mt ON phe.pool = mt.pool AND COALESCE(phe.first_seen_timestamp, phe.mtp_timestamp) = mt.max_effective_timestamp WHERE p.withdrawn_in_utxo IS NULL "; let mut statement = connection.prepare(sql)?; let mut rows = statement.query(params![timestamp, token_id.to_hex()])?; let mut sum_sats: u64 = 0; let mut sum_tokens: u64 = 0; let mut latest_timestamp = 0; while let Some(row) = rows.next()? { let token_amount: i64 = row.get(0)?; let sats: i64 = row.get(1)?; let row_timestamp: i64 = row.get(2)?; if sats >= 0 && token_amount >= 0 { sum_sats += sats as u64; sum_tokens += token_amount as u64; } if row_timestamp > latest_timestamp { latest_timestamp = row_timestamp; } } let sum_sats_decimal = Decimal::from_u64(sum_sats).unwrap_or_default(); let sum_tokens_decimal = Decimal::from_u64(sum_tokens).unwrap_or_default(); let overall_price = sum_sats_decimal .checked_div(sum_tokens_decimal) .context("Division failed: sum_tokens is zero or invalid")?; let overall_price_f64 = overall_price.to_f64().context("Conversion to f64 failed")?; Ok((latest_timestamp, overall_price_f64)) } #[get("/price//at/")] pub fn price_at( token: &str, timestamp: &str, conn: &State, ) -> Result, Custom> { let timestamp: i64 = timestamp.parse().map_err(|_| { Custom( Status::BadRequest, "Invalid timestamp format. Must be a valid number.".to_string(), ) })?; let db = conn .cauldron_r .get() .map_err(|e| Custom(Status::InternalServerError, format!("Error: {}", e)))?; let current_time = time_now(); if timestamp > current_time { return Err(Custom( Status::BadRequest, "Timestamp is in the future".to_string(), )); } let token = TokenID::from_hex(token) .map_err(|e| Custom(Status::BadRequest, format!("Error: {}", e)))?; match price_at_or_before(&db, timestamp, &token) { Ok((latest_timestamp, price)) => Ok(Json(json!({ "timestamp": latest_timestamp, "price": price }))), Err(e) => Err(Custom( Status::InternalServerError, format!("Error fetching price: {}", e), )), } } #[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) }))) } #[cfg(test)] mod tests { use crate::db::cauldron::{ pool::{ self, dummy_init_seq, flag_as_withdrawn, insert_new_pool, insert_pool_history_entry, }, tx::{self, insert_block_tx, insert_mempool_tx}, utxo_funding::{self, insert_utxo_funding}, }; use crate::utiltest::mock_db_pool; use super::*; use bitcoin_hashes::Hash; use bitcoincash::{BlockHash, PubkeyHash, Txid}; use riftenlabs_defi::{cauldron::ParsedContract, chainutil::OutPointHash}; use rocket::http::Status; use rocket::local::blocking::Client; use rocket::routes; use rusqlite::Connection; const TIME_1: u64 = 1727963300; const TIME_2: u64 = 1727963350; const TIME_3: u64 = 1727963400; fn dummy_cauldron( txid: &Txid, utxo: &OutPointHash, token: &TokenID, sats: u64, tokens: i64, pkh: &PubkeyHash, ) -> ParsedContract { ParsedContract { pkh: *pkh, is_withdrawn: false, spent_utxo_hash: OutPointHash::all_zeros(), new_utxo_hash: Some(*utxo), new_utxo_txid: Some(*txid), new_utxo_n: Some(0), token_id: Some(*token), sats: Some(sats), token_amount: Some(tokens), } } fn setup_mock_db(conn: &Connection) { utxo_funding::create_table(conn); tx::create_table(conn); pool::create_table(conn); dummy_init_seq(); let token_zero = TokenID::all_zeros(); let pkh_zero = PubkeyHash::all_zeros(); let txid1 = Txid::from_inner([0xf0; 32]); let txid2 = Txid::from_inner([0xf1; 32]); let txid3 = Txid::from_inner([0xf2; 32]); let utxo1 = OutPointHash::from_inner([0xe0; 32]); let utxo2 = OutPointHash::from_inner([0xe1; 32]); let utxo3 = OutPointHash::from_inner([0xe2; 32]); // Insert mock data into utxo_funding let cauldron1 = dummy_cauldron(&txid1, &utxo1, &token_zero, 50000, 1000, &pkh_zero); let cauldron2 = dummy_cauldron(&txid2, &utxo2, &token_zero, 60000, 2000, &pkh_zero); let cauldron3 = dummy_cauldron(&txid2, &utxo3, &token_zero, 90000, 3000, &pkh_zero); insert_utxo_funding(conn, &vec![cauldron1.clone()], &txid1, true).unwrap(); insert_utxo_funding(conn, &vec![cauldron2.clone()], &txid2, true).unwrap(); insert_utxo_funding(conn, &vec![cauldron3.clone()], &txid3, true).unwrap(); // Insert mock data into tx table let block_zero = BlockHash::all_zeros(); insert_block_tx(conn, &txid1, &block_zero, TIME_1 as i64).unwrap(); insert_mempool_tx(conn, &txid1, TIME_1).unwrap(); insert_block_tx(conn, &txid2, &block_zero, TIME_2 as i64).unwrap(); insert_mempool_tx(conn, &txid2, TIME_2).unwrap(); insert_block_tx(conn, &txid3, &block_zero, TIME_3 as i64).unwrap(); insert_mempool_tx(conn, &txid3, TIME_3).unwrap(); // Insert mock data into `pool_history_entry` let pool1 = OutPointHash::from_inner([0x0a; 32]); let pool2 = OutPointHash::from_inner([0x0b; 32]); let pool3 = OutPointHash::from_inner([0x0c; 32]); let txid1_newer = Txid::from_inner([0xf3; 32]); let utxo1_newer = OutPointHash::from_inner([0xe3; 32]); insert_pool_history_entry(conn, &pool1, &cauldron1, Some(TIME_1), Some(TIME_1)).unwrap(); insert_pool_history_entry(conn, &pool2, &cauldron2, Some(TIME_2), Some(TIME_2)).unwrap(); insert_pool_history_entry(conn, &pool3, &cauldron3, Some(TIME_3), Some(TIME_3)).unwrap(); // Insert newer entry for pool1 let cauldron1_newer = dummy_cauldron( &txid1_newer, &utxo1_newer, &token_zero, 70000, 1500, &pkh_zero, ); insert_pool_history_entry( conn, &pool1, &cauldron1_newer, Some(1727963500), Some(1727963500), ) .unwrap(); insert_utxo_funding(conn, &vec![cauldron1_newer], &txid1_newer, true).unwrap(); // Insert active pools with required columns (owner_pkh and token_id) let token1 = TokenID::from_inner([0xda; 32]); let token2 = TokenID::from_inner([0xdb; 32]); let token3 = TokenID::from_inner([0xdc; 32]); let pkh1 = PubkeyHash::from_inner([0xca; 20]); let pkh2 = PubkeyHash::from_inner([0xcb; 20]); let pkh3 = PubkeyHash::from_inner([0xcc; 20]); insert_new_pool( conn, &dummy_cauldron(&Txid::all_zeros(), &pool1, &token1, 0, 0, &pkh1), ) .unwrap(); insert_new_pool( conn, &dummy_cauldron(&Txid::all_zeros(), &pool2, &token2, 0, 0, &pkh2), ) .unwrap(); insert_new_pool( conn, &dummy_cauldron(&Txid::all_zeros(), &pool3, &token3, 0, 0, &pkh3), ) .unwrap(); // Insert an inactive pool let inactive_pool = OutPointHash::from_inner([0xaa; 32]); //let inactive_withdrawn_utxo = OutPointHash::from_inner([0xab; 32]); let inactive_token = TokenID::from_inner([0xac; 32]); let inactive_pkh = PubkeyHash::from_inner([0xac; 20]); let inactive_txid = Txid::from_inner([0xad; 32]); let inactive_cauldron = dummy_cauldron( &inactive_txid, &inactive_pool, &inactive_token, 80000, 2500, &inactive_pkh, ); let inactive_cauldron_withdraw = ParsedContract { pkh: inactive_pkh, is_withdrawn: true, spent_utxo_hash: inactive_pool, new_utxo_hash: None, new_utxo_txid: None, new_utxo_n: None, token_id: None, sats: None, token_amount: None, }; insert_new_pool(conn, &inactive_cauldron).unwrap(); insert_utxo_funding(conn, &vec![inactive_cauldron.clone()], &inactive_txid, true).unwrap(); insert_pool_history_entry( conn, &inactive_pool, &inactive_cauldron, Some(1727963350), Some(1727963350), ) .unwrap(); flag_as_withdrawn(conn, &inactive_pool, &inactive_cauldron_withdraw).unwrap(); } #[test] fn test_price_at_specific_time() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let token_id = TokenID::all_zeros().to_hex(); // Test 1: Querying at a timestamp that exactly matches test_txid1 let timestamp = TIME_1; let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::Ok); let json_value: serde_json::Value = serde_json::from_str(response.into_string().unwrap().as_str()).unwrap(); let actual_price = json_value["price"] .as_f64() .expect("Price field is not a valid f64"); // Expected price based on 50,000 sats and 1,000 tokens let expected_price = 50.0; assert!((actual_price - expected_price).abs() < 0.01); // Test 2: Querying at a timestamp that includes Pool 1 and Pool 2 let timestamp = TIME_2; let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::Ok); let json_value: serde_json::Value = serde_json::from_str(response.into_string().unwrap().as_str()).unwrap(); let actual_price = json_value["price"] .as_f64() .expect("Price field is not a valid f64"); // The combined price should be 36.67 (rounded) let expected_price = 36.67; assert!( (actual_price - expected_price).abs() < 0.01, "expected {} != actual {}", expected_price, actual_price ); // Test 3: Querying a timestamp that includes Pool 1, Pool 2, and Pool 3 let timestamp = TIME_3; let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::Ok); let json_value: serde_json::Value = serde_json::from_str(response.into_string().unwrap().as_str()).unwrap(); let actual_price = json_value["price"] .as_f64() .expect("Price field is not a valid f64"); // The combined price should be 33.33 (rounded) let expected_price = 33.33; assert!((actual_price - expected_price).abs() < 0.01); } #[test] fn test_price_at_newer_timestamp_for_pool1() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db.clone()) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let token_id = TokenID::all_zeros().to_hex(); // Test: Query at a newer timestamp (1727963500) for pool1 and ensure it only accounts for the newer entry let timestamp = 1727963500; // Newer timestamp let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::Ok); let json_value: serde_json::Value = serde_json::from_str(response.into_string().unwrap().as_str()).unwrap(); let actual_price = json_value["price"] .as_f64() .expect("Price field is not a valid f64"); // Expected price based on the total from pool1, pool2, and pool3 let expected_price = 33.85; // Rounded to 2 decimal places assert!( (actual_price - expected_price).abs() < 0.01, "expected {} != actual {}", expected_price, actual_price ); } #[test] fn test_price_with_multiple_entries_at_same_timestamp() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db.clone()) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let token_id = TokenID::all_zeros().to_hex(); // Insert additional entries with the same timestamp for an existing pool (e.g., pool1) let conn = mock_db.cauldron_w.get().expect("Failed to get connection."); // These entries should have the same timestamp as previous mockdata and be counted in the price calculation let pool1 = OutPointHash::from_inner([0x0a; 32]); let txid = Txid::hash("txid_extra".as_bytes()); let cauldron = dummy_cauldron( &txid, &OutPointHash::hash("utxo_extra".as_bytes()), &TokenID::all_zeros(), 40000, 500, &PubkeyHash::all_zeros(), ); insert_pool_history_entry(&conn, &pool1, &cauldron, Some(TIME_1), Some(TIME_1)).unwrap(); insert_utxo_funding(&conn, &vec![cauldron], &txid, true).unwrap(); // Test: Query at the timestamp matching test_txid1 (1727963300) and check the price let timestamp = TIME_1; let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::Ok); let json_value: serde_json::Value = serde_json::from_str(response.into_string().unwrap().as_str()).unwrap(); let actual_price = json_value["price"] .as_f64() .expect("Price field is not a valid f64"); // Expected price based on (50,000 + 40,000) sats and (1,000 + 500) tokens = 90,000 / 1,500 = 60.0 let expected_price = 60.0; assert!((actual_price - expected_price).abs() < 0.01); } #[test] fn test_bad_token_hash() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db) .mount("/cauldron", routes![price_current, price_history, price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); // Test the `/price//at/` endpoint with a bad token hash let bad_token_id = "bad_token_id"; let timestamp = 1727963432; // Arbitrary timestamp let response = client .get(format!("/cauldron/price/{}/at/{}", bad_token_id, timestamp)) .dispatch(); assert_eq!(response.status(), Status::BadRequest); } #[test] fn test_timestamp_in_future() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let token_id = TokenID::all_zeros().to_hex(); let future_timestamp = (time_now() + 100000).to_string(); let response = client .get(format!( "/cauldron/price/{}/at/{}", token_id, future_timestamp )) .dispatch(); assert_eq!(response.status(), Status::BadRequest); let body = response.into_string().unwrap(); assert!(body.contains("Timestamp is in the future")); } #[test] fn test_invalid_timestamp_format() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); // Test the `/price//at/` endpoint with an invalid timestamp let token_id = TokenID::all_zeros().to_hex(); let invalid_timestamp = "ASDASD:"; let response = client .get(format!( "/cauldron/price/{}/at/{}", token_id, invalid_timestamp )) .dispatch(); assert_eq!(response.status(), Status::BadRequest); let body = response.into_string().unwrap(); assert!(body.contains("Invalid timestamp format")); } #[test] fn test_price_with_high_tokens_and_low_sats() { let mock_db = mock_db_pool(setup_mock_db); let rocket = rocket::build() .manage(mock_db.clone()) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let conn = mock_db.cauldron_w.get().expect("Failed to get connection."); // Insert high tokens and low sats let pool = OutPointHash::hash("many tokens pool".as_bytes()); let txid = Txid::hash("many tokens txid".as_bytes()); let utxo = OutPointHash::hash("many tokens utxo".as_bytes()); let token = TokenID::hash("many tokens tokenid".as_bytes()); let cauldron = dummy_cauldron( &txid, &utxo, &token, 1, /* low sats */ 9999999999999999, /* many tokens */ &PubkeyHash::all_zeros(), ); insert_utxo_funding(&conn, &vec![cauldron.clone()], &txid, true).unwrap(); insert_pool_history_entry(&conn, &pool, &cauldron, Some(TIME_1), Some(TIME_1)).unwrap(); // Test: Querying the price with high tokens and low sats let response = client .get(format!("/cauldron/price/{}/at/{}", token.to_hex(), TIME_1)) .dispatch(); // Expect that we could not handle the calculation/conversion. assert_eq!(response.status(), Status::InternalServerError); } }