// 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::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) } 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, tx, utxo_funding}; use super::*; use bitcoin_hashes::Hash; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rocket::http::Status; use rocket::local::blocking::Client; use rocket::routes; use rusqlite::{params, Connection}; use std::sync::Arc; fn setup_mock_db(connection: &Connection) { utxo_funding::create_table(connection); tx::create_table(connection); pool::create_table(connection); // Insert mock data into utxo_funding connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["mock_utxo_hash1", "test_txid1", 50000, 1000, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert test data 1 into utxo_funding"); connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["mock_utxo_hash2", "test_txid2", 60000, 2000, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert test data 2 into utxo_funding"); connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["mock_utxo_hash3", "test_txid3", 90000, 3000, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert test data 3 into utxo_funding"); // Insert mock data into tx table connection .execute( "INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)", params!["test_txid1", 1727963300, 1727963300], ) .expect("Failed to insert test data 1 into tx"); connection .execute( "INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)", params!["test_txid2", 1727963350, 1727963350], ) .expect("Failed to insert test data 2 into tx"); connection .execute( "INSERT INTO tx (txid, first_seen_timestamp, mtp_timestamp) VALUES (?, ?, ?)", params!["test_txid3", 1727963400, 1727963400], ) .expect("Failed to insert test data 3 into tx"); // Insert mock data into `pool_history_entry` connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["pool1", "mock_utxo_hash1", "test_txid1", "tx_pos1", 1727963300, 1727963300], ) .expect("Failed to insert test data 1 into pool_history_entry"); connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["pool2", "mock_utxo_hash2", "test_txid2", "tx_pos2", 1727963350, 1727963350], ) .expect("Failed to insert test data 2 into pool_history_entry"); connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["pool3", "mock_utxo_hash3", "test_txid3", "tx_pos3", 1727963400, 1727963400], ) .expect("Failed to insert test data 3 into pool_history_entry"); // Insert newer entry for pool1 connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["pool1", "mock_utxo_hash1_newer", "test_txid1_newer", "tx_pos1_newer", 1727963500, 1727963500], ) .expect("Failed to insert newer test data into pool_history_entry"); connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["mock_utxo_hash1_newer", "test_txid1_newer", 70000, 1500, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert newer test data into utxo_funding"); // Insert active pools with required columns (owner_pkh and token_id) connection .execute( "INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)", params!["pool1", "dummy_owner_pkh1", "dummy_token_id1", Option::::None], // Active pool ) .expect("Failed to insert test data 1 into pool"); connection .execute( "INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)", params!["pool2", "dummy_owner_pkh2", "dummy_token_id2", Option::::None], // Active pool ) .expect("Failed to insert test data 2 into pool"); connection .execute( "INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)", params!["pool3", "dummy_owner_pkh3", "dummy_token_id3", Option::::None], // Active pool ) .expect("Failed to insert test data 3 into pool"); // Insert an inactive pool connection .execute( "INSERT INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo) VALUES (?, ?, ?, ?)", params!["inactive_pool", "dummy_owner_pkh_inactive", "dummy_token_id_inactive", "inactive_utxo"], // Inactive pool ) .expect("Failed to insert inactive pool"); // Insert corresponding utxo_funding for the inactive pool connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["inactive_utxo", "inactive_txid", 80000, 2500, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert inactive utxo_funding"); // Insert pool_history_entry for the inactive pool connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["inactive_pool", "inactive_utxo", "inactive_txid", "tx_pos_inactive", 1727963350, 1727963350], ) .expect("Failed to insert inactive pool_history_entry"); } // Mock function to create a DB pool fn mock_db_pool() -> DB { let manager = SqliteConnectionManager::memory(); let pool = Pool::new(manager).expect("Failed to create pool."); let write_conn = pool.get().expect("Failed to get connection."); setup_mock_db(&write_conn); DB { cauldron_w: Arc::new(pool.clone()), cauldron_r: Arc::new(pool.clone()), bcmr_w: Arc::new(pool.clone()), bcmr_r: Arc::new(pool.clone()), crc20_w: Arc::new(pool.clone()), crc20_r: Arc::new(pool.clone()), } } #[test] fn test_price_at_specific_time() { let mock_db = mock_db_pool(); 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 = 1727963300; 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 = 1727963350; 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); // Test 3: Querying a timestamp that includes Pool 1, Pool 2, and Pool 3 let timestamp = 1727963400; 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(); 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); } #[test] fn test_price_with_multiple_entries_at_same_timestamp() { let mock_db = mock_db_pool(); 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 connection = mock_db.cauldron_r.get().expect("Failed to get connection."); // These entries should have the same timestamp as previous mockdata and be counted in the price calculation connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params!["pool1", "mock_utxo_hash1_extra", "test_txid1_extra", "0", 1727963300, 1727963300], ) .expect("Failed to insert extra test data into pool_history_entry"); connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params!["mock_utxo_hash1_extra", "test_txid1_extra", 40000, 500, "0000000000000000000000000000000000000000000000000000000000000000"], ) .expect("Failed to insert extra test data into utxo_funding"); // Test: Query at the timestamp matching test_txid1 (1727963300) and check the price let timestamp = 1727963300; 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(); 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(); 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(); 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(); let rocket = rocket::build() .manage(mock_db.clone()) .mount("/cauldron", routes![price_at]); let client = Client::tracked(rocket).expect("valid rocket instance"); let connection = mock_db.cauldron_r.get().expect("Failed to get connection."); // Setup mock data for this specific test connection .execute( "CREATE TABLE IF NOT EXISTS utxo_funding ( new_utxo_hash TEXT PRIMARY KEY, txid TEXT, sats BIGINT, token_amount BIGINT, token_id TEXT )", [], ) .expect("Failed to create utxo_funding table"); connection .execute( "CREATE TABLE IF NOT EXISTS pool_history_entry ( pool TEXT, utxo TEXT PRIMARY KEY, txid TEXT, tx_pos TEXT, mtp_timestamp BIGINT, first_seen_timestamp BIGINT )", [], ) .expect("Failed to create pool_history_entry table"); // Insert high tokens and low sats connection .execute( "INSERT INTO utxo_funding (new_utxo_hash, txid, sats, token_amount, token_id) VALUES (?, ?, ?, ?, ?)", params![ "mock_utxo_hash_high_tokens", "test_txid_high_tokens", 1_i64, // Low sats 9999999999999999_i64, // Very high token amount "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ], ) .expect("Failed to insert high-token data into utxo_funding"); connection .execute( "INSERT INTO pool_history_entry (pool, utxo, txid, tx_pos, mtp_timestamp, first_seen_timestamp) VALUES (?, ?, ?, ?, ?, ?)", params![ "pool_high_tokens", "mock_utxo_hash_high_tokens", "test_txid_high_tokens", "tx_pos_high_tokens", 1727963300, 1727963300 ], ) .expect("Failed to insert high-token data into pool_history_entry"); // Test: Querying the price with high tokens and low sats let token_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let timestamp = 1727963300; let response = client .get(format!("/cauldron/price/{}/at/{}", token_id, timestamp)) .dispatch(); // Expect that we could not handle the calculation/conversion. assert_eq!(response.status(), Status::InternalServerError); } }